Repository: qier222/YesPlayMusic
Branch: master
Commit: 729761da7e2c
Files: 126
Total size: 565.3 KB
Directory structure:
gitextract_z_keyuc0/
├── .dockerignore
├── .editorconfig
├── .envrc
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ └── ----------.md
│ └── workflows/
│ ├── build.yaml
│ └── sync.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── Dockerfile
├── LICENSE
├── README.md
├── babel.config.js
├── build/
│ └── icons/
│ └── icon.icns
├── devenv.nix
├── devenv.yaml
├── docker/
│ └── nginx.conf.example
├── docker-compose.yml
├── install-replit.sh
├── jsconfig.json
├── package.json
├── public/
│ ├── img/
│ │ └── icons/
│ │ └── icon.icns
│ ├── index.html
│ └── robots.txt
├── restyled.yml
├── src/
│ ├── App.vue
│ ├── api/
│ │ ├── album.js
│ │ ├── artist.js
│ │ ├── auth.js
│ │ ├── lastfm.js
│ │ ├── mv.js
│ │ ├── others.js
│ │ ├── playlist.js
│ │ ├── track.js
│ │ └── user.js
│ ├── assets/
│ │ ├── css/
│ │ │ ├── global.scss
│ │ │ ├── nprogress.css
│ │ │ ├── plyr.css
│ │ │ └── slider.css
│ │ └── icons/
│ │ └── index.js
│ ├── background.js
│ ├── components/
│ │ ├── ArtistsInLine.vue
│ │ ├── ButtonIcon.vue
│ │ ├── ButtonTwoTone.vue
│ │ ├── ContextMenu.vue
│ │ ├── Cover.vue
│ │ ├── CoverRow.vue
│ │ ├── DailyTracksCard.vue
│ │ ├── ExplicitSymbol.vue
│ │ ├── FMCard.vue
│ │ ├── LinuxTitlebar.vue
│ │ ├── Modal.vue
│ │ ├── ModalAddTrackToPlaylist.vue
│ │ ├── ModalNewPlaylist.vue
│ │ ├── MvRow.vue
│ │ ├── Navbar.vue
│ │ ├── Player.vue
│ │ ├── Scrollbar.vue
│ │ ├── SvgIcon.vue
│ │ ├── Toast.vue
│ │ ├── TrackList.vue
│ │ ├── TrackListItem.vue
│ │ └── Win32Titlebar.vue
│ ├── electron/
│ │ ├── dockMenu.js
│ │ ├── globalShortcut.js
│ │ ├── ipcMain.js
│ │ ├── ipcRenderer.js
│ │ ├── menu.js
│ │ ├── mpris.js
│ │ ├── services.js
│ │ ├── touchBar.js
│ │ └── tray.js
│ ├── locale/
│ │ ├── index.js
│ │ └── lang/
│ │ ├── en.js
│ │ ├── tr.js
│ │ ├── zh-CN.js
│ │ └── zh-TW.js
│ ├── main.js
│ ├── ncmModDef.js
│ ├── registerServiceWorker.js
│ ├── router/
│ │ └── index.js
│ ├── store/
│ │ ├── actions.js
│ │ ├── index.js
│ │ ├── initLocalStorage.js
│ │ ├── mutations.js
│ │ ├── plugins/
│ │ │ ├── localStorage.js
│ │ │ └── sendSettings.js
│ │ └── state.js
│ ├── utils/
│ │ ├── Player.js
│ │ ├── auth.js
│ │ ├── base64.js
│ │ ├── checkAuthToken.js
│ │ ├── common.js
│ │ ├── db.js
│ │ ├── filters.js
│ │ ├── lyrics.js
│ │ ├── nativeAlert.js
│ │ ├── platform.js
│ │ ├── playList.js
│ │ ├── request.js
│ │ ├── shortcuts.js
│ │ ├── staticData.js
│ │ └── updateApp.js
│ └── views/
│ ├── album.vue
│ ├── artist.vue
│ ├── artistMV.vue
│ ├── dailyTracks.vue
│ ├── explore.vue
│ ├── home.vue
│ ├── lastfmCallback.vue
│ ├── library.vue
│ ├── login.vue
│ ├── loginAccount.vue
│ ├── loginUsername.vue
│ ├── lyrics.vue
│ ├── mv.vue
│ ├── newAlbum.vue
│ ├── next.vue
│ ├── playlist.vue
│ ├── search.vue
│ ├── searchType.vue
│ └── settings.vue
├── vercel.example.json
└── vue.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
.git
.github
.gitignore
README.md
LICENSE
.vscode
dist
dist_electron
build
images
script
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
================================================
FILE: .envrc
================================================
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
export NIXPKGS_ALLOW_INSECURE=1
use devenv
================================================
FILE: .gitattributes
================================================
* text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.mp3 binary
*.icns binary
*.gif binary
================================================
FILE: .github/ISSUE_TEMPLATE/----------.md
================================================
---
name: 反馈问题或请求新功能
about: bug & feature
title: ''
labels: ''
assignees: ''
---
# 尽量每个 issue 只提一个 bug 或新功能
### 提新 issue 前请确认 👉
- 没人提过这个 issue([这里看所有 issue](https://github.com/qier222/YesPlayMusic/issues))
- 项目的 Todo 里没有与你 issue 相关的内容([这里看 Todo](https://github.com/qier222/YesPlayMusic/projects/1))
### 反馈 bug 需要的信息
- 用的是网页版还是客户端
- 浏览器名称或电脑操作系统
- 控制台 Console 页面的截图(按 F12 可打开控制台)
================================================
FILE: .github/workflows/build.yaml
================================================
name: Release
env:
YARN_INSTALL_NOPT: yarn add --ignore-platform --ignore-optional
on:
push:
branches:
- master
tags:
- v*
workflow_dispatch:
jobs:
release:
runs-on: ${{ matrix.os }}
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-22.04]
steps:
- name: Check out Git repository
uses: actions/checkout@v3
with:
submodules: 'recursive'
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v3
with:
node-version: 16
cache: 'yarn'
- name: Install RPM & Pacman (on Ubuntu)
if: runner.os == 'Linux'
run: |
sudo apt-get update &&
sudo apt-get install --no-install-recommends -y rpm &&
sudo apt-get install --no-install-recommends -y libarchive-tools &&
sudo apt-get install --no-install-recommends -y libopenjp2-tools
- name: Install Snapcraft (on Ubuntu)
uses: samuelmeuli/action-snapcraft@v3
if: startsWith(matrix.os, 'ubuntu')
- id: get_unm_version
name: Get the installed UNM version
run: |
yarn --ignore-optional
unm_version=$(node -e "console.log(require('./node_modules/@unblockneteasemusic/rust-napi/package.json').version)")
echo "::set-output name=unmver::${unm_version}"
shell: bash
- name: Install UNM dependencies for Windows
if: runner.os == 'Windows'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-win32-x64-msvc@${{steps.get_unm_version.outputs.unmver}}
shell: bash
- name: Install UNM dependencies for macOS
if: runner.os == 'macOS'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-darwin-x64@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-darwin-arm64@${{steps.get_unm_version.outputs.unmver}} \
dmg-license
shell: bash
- name: Install UNM dependencies for Linux
if: runner.os == 'Linux'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-linux-x64-gnu@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-linux-arm64-gnu@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-linux-arm-gnueabihf@${{steps.get_unm_version.outputs.unmver}}
shell: bash
- name: Build/release Electron app
uses: samuelmeuli/action-electron-builder@v1.6.0
env:
VUE_APP_NETEASE_API_URL: /api
VUE_APP_ELECTRON_API_URL: /api
VUE_APP_ELECTRON_API_URL_DEV: http://127.0.0.1:10754
VUE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c
VUE_APP_LASTFM_API_SHARED_SECRET: 307c9fda32b3904e53654baff215cb67
with:
# GitHub token, automatically provided to the action
# (No need to define this secret in the repo settings)
github_token: ${{ secrets.github_token }}
# If the commit is tagged with a version (e.g. "v1.0.0"),
# release the app after building
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
use_vue_cli: true
- uses: actions/upload-artifact@v4
with:
name: YesPlayMusic-mac
path: dist_electron/*-universal.dmg
if-no-files-found: ignore
- uses: actions/upload-artifact@v4
with:
name: YesPlayMusic-win
path: dist_electron/*Setup*.exe
if-no-files-found: ignore
- uses: actions/upload-artifact@v4
with:
name: YesPlayMusic-linux
path: dist_electron/*.AppImage
if-no-files-found: ignore
================================================
FILE: .github/workflows/sync.yml
================================================
name: Upstream Sync
permissions:
contents: write
issues: write
actions: write
on:
schedule:
- cron: '0 * * * *' # every hour
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Clean issue notice
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
labels: '🚨 Sync Fail'
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: qier222/YesPlayMusic
upstream_sync_branch: master
target_sync_branch: master
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false
- name: Sync check
if: failure()
uses: actions-cool/issues-helper@v3
with:
actions: 'create-issue'
title: '🚨 同步失败 | Sync Fail'
labels: '🚨 Sync Fail'
body: |
Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork.
由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次。
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/dist
# local env files
.env
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel
#Electron-builder output
/dist_electron
NeteaseCloudMusicApi-master
NeteaseCloudMusicApi-master.zip
# Local Netlify folder
.netlify
vercel.json
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
================================================
FILE: .nvmrc
================================================
14
================================================
FILE: .prettierignore
================================================
build
coverage
dist
================================================
FILE: .prettierrc
================================================
{
"trailingComma": "es5",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "strict"
}
================================================
FILE: Dockerfile
================================================
FROM node:16.13.1-alpine AS build
ENV VUE_APP_NETEASE_API_URL=/api
WORKDIR /app
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&\
apk add --no-cache python3 make g++ git
COPY package.json yarn.lock ./
RUN yarn config set electron_mirror https://npmmirror.com/mirrors/electron/ && \
yarn config set registry https://registry.npmmirror.com && \
sed -i 's/registry.yarnpkg.com/registry.npmmirror.com/g' yarn.lock && \
sed -i 's/registry.npmjs.org/registry.npmmirror.com/g' yarn.lock && \
yarn install
COPY . .
RUN yarn build
FROM nginx:1.20.2-alpine AS app
COPY --from=build /app/package.json /usr/local/lib/
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories \
&& apk add --no-cache libuv nodejs npm \
&& npm config set registry https://registry.npmmirror.com \
&& npm i -g $(awk -F \" '{if($2=="@neteaseapireborn/api@latest") print $2"@"$4}' /usr/local/lib/package.json) \
&& rm -f /usr/local/lib/package.json
COPY --from=build /app/docker/nginx.conf.example /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
CMD ["sh", "-c", "nginx && exec npx @neteaseapireborn/api@latest"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020-2023 qier222
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
---
YesPlayMusic
高颜值的第三方网易云播放器
🌎 访问DEMO |
📦️ 下载安装包 |
💬 加入交流群
[![Library][library-screenshot]](https://music.qier222.com)
## 全新版本
全新 2.0 Alpha 测试版已发布,欢迎前往 [Releases](https://github.com/qier222/YesPlayMusic/releases) 页面下载。
当前版本将会进入维护模式,除重大 bug 修复外,不会再更新新功能。
## ✨ 特性
- ✅ 使用 Vue.js 全家桶开发
- 🔴 网易云账号登录(扫码/手机/邮箱登录)
- 📺 支持 MV 播放
- 📃 支持歌词显示
- 📻 支持私人 FM / 每日推荐歌曲
- 🚫🤝 无任何社交功能
- 🌎️ 海外用户可直接播放(需要登录网易云账号)
- 🔐 支持 [UnblockNeteaseMusic](https://github.com/UnblockNeteaseMusic/server#音源清单),自动使用[各类音源](https://github.com/UnblockNeteaseMusic/server#音源清单)替换变灰歌曲链接 (网页版不支持)
- 「各类音源」指默认启用的音源。
- YouTube 音源需自行安装 `yt-dlp`。
- ~~✔️ 每日自动签到(手机端和电脑端同时签到)~~
- 🌚 Light/Dark Mode 自动切换
- 👆 支持 Touch Bar
- 🖥️ 支持 PWA,可在 Chrome/Edge 里点击地址栏右边的 ➕ 安装到电脑
- 🟥 支持 Last.fm Scrobble
- ☁️ 支持音乐云盘
- ⌨️ 自定义快捷键和全局快捷键
- 🎧 支持 Mpris
- 🛠 更多特性开发中
## 📦️ 安装
Electron 版本由 [@hawtim](https://github.com/hawtim) 和 [@qier222](https://github.com/qier222) 适配并维护,支持 macOS、Windows、Linux。
访问本项目的 [Releases](https://github.com/qier222/YesPlayMusic/releases)
页面下载安装包。
- macOS 用户可以通过 Homebrew 来安装:`brew install --cask yesplaymusic`
- Windows 用户可以通过 Scoop 来安装:`scoop install extras/yesplaymusic`
## 同类项目(排名无先后)
欢迎大家通过 PR 分享你的项目,让更多人看到!
- [algerkong/AlgerMusicPlayer](https://github.com/algerkong/AlgerMusicPlayer)
- [asxez/MusicBox](https://github.com/asxez/MusicBox)
- [lianchengwu/wmplayer](https://github.com/lianchengwu/wmplayer)
## ⚙️ 部署至 Vercel
除了下载安装包使用,你还可以将本项目部署到 Vercel 或你的服务器上。下面是部署到 Vercel 的方法。
本项目的 Demo (https://music.qier222.com) 就是部署在 Vercel 上的网站。
[](https://vercel.com/?utm_source=ohmusic&utm_campaign=oss)
1. 部署网易云 API,详情参见 [Binaryify/NeteaseCloudMusicApi](https://neteasecloudmusicapi.vercel.app/#/?id=%e5%ae%89%e8%a3%85)
。你也可以将 API 部署到 Vercel。
2. 点击本仓库右上角的 Fork,复制本仓库到你的 GitHub 账号。
3. 点击仓库的 Add File,选择 Create new file,输入 `vercel.json`,将下面的内容复制粘贴到文件中,并将 `https://your-netease-api.example.com` 替换为你刚刚部署的网易云 API 地址:
```json
{
"rewrites": [
{
"source": "/api/:match*",
"destination": "https://your-netease-api.example.com/:match*"
}
]
}
```
4. 打开 [Vercel.com](https://vercel.com),使用 GitHub 登录。
5. 点击 Import Git Repository 并选择你刚刚复制的仓库并点击 Import。
6. 点击 PERSONAL ACCOUNT 旁边的 Select。
7. 点击 Environment Variables,填写 Name 为 `VUE_APP_NETEASE_API_URL`,Value 为 `/api`,点击 Add。最后点击底部的 Deploy 就可以部署到
Vercel 了。
## ⚙️ 部署到自己的服务器
除了部署到 Vercel,你还可以部署到自己的服务器上
1. 部署网易云 API,详情参见 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
2. 克隆本仓库
```sh
git clone --recursive https://github.com/qier222/YesPlayMusic.git
```
3. 安装依赖
```sh
yarn install
```
4. (可选)使用 Nginx 反向代理 API,将 API 路径映射为 `/api`,如果 API 和网页不在同一个域名下的话(跨域),会有一些 bug。
5. 复制 `/.env.example` 文件为 `/.env`,修改里面 `VUE_APP_NETEASE_API_URL` 的值为网易云 API 地址。本地开发的话可以填写 API 地址为 `http://localhost:3000`,YesPlayMusic 地址为 `http://localhost:8080`。如果你使用了反向代理 API,可以填写 API 地址为 `/api`。
```
VUE_APP_NETEASE_API_URL=http://localhost:3000
```
6. 编译打包
```sh
yarn run build
```
7. 将 `/dist` 目录下的文件上传到你的 Web 服务器
## ⚙️ 宝塔面板 docker 应用商店 部署
1. 安装宝塔面板,前往[宝塔面板官网](https://www.bt.cn/new/download.html) ,选择正式版的脚本下载安装。
2. 安装后登录宝塔面板,在左侧导航栏中点击 Docker,首次进入会提示安装 Docker 服务,点击立即安装,按提示完成安装
3. 安装完成后在应用商店中找到 YesPlayMusic,点击安装,配置域名、端口等基本信息即可完成安装。
4. 安装后在浏览器输入上一步骤设置的域名即可访问。
## ⚙️ Docker 部署
1. 构建 Docker Image
```sh
docker build -t yesplaymusic .
```
2. 启动 Docker Container
```sh
docker run -d --name YesPlayMusic -p 80:80 yesplaymusic
```
3. Docker Compose 启动
```sh
docker-compose up -d
```
YesPlayMusic 地址为 `http://localhost`
## ⚙️ 部署至 Replit
1. 新建 Repl,选择 Bash 模板
2. 在 Replit shell 中运行以下命令
```sh
bash <(curl -s -L https://raw.githubusercontent.com/qier222/YesPlayMusic/main/install-replit.sh)
```
3. 首次运行成功后,只需点击绿色按钮 `Run` 即可再次运行
4. 由于 replit 个人版限制内存为 1G(教育版为 3G),构建过程中可能会失败,请再次运行上述命令或运行以下命令:
```sh
cd /home/runner/${REPL_SLUG}/music && yarn install && yarn run build
```
## 👷♂️ 打包客户端
如果在 Release 页面没有找到适合你的设备的安装包的话,你可以根据下面的步骤来打包自己的客户端。
1. 打包 Electron 需要用到 Node.js 和 Yarn。可前往 [Node.js 官网](https://nodejs.org/zh-cn/) 下载安装包。安装 Node.js
后可在终端里执行 `npm install -g yarn` 来安装 Yarn。
2. 使用 `git clone --recursive https://github.com/qier222/YesPlayMusic.git` 克隆本仓库到本地。
3. 使用 `yarn install` 安装项目依赖。
4. 复制 `/.env.example` 文件为 `/.env` 。
5. 选择下列表格的命令来打包适合的你的安装包,打包出来的文件在 `/dist_electron` 目录下。了解更多信息可访问 [electron-builder 文档](https://www.electron.build/cli)
| 命令 | 说明 |
| ------------------------------------------ | ------------------------- |
| `yarn electron:build --windows nsis:ia32` | Windows 32 位 |
| `yarn electron:build --windows nsis:arm64` | Windows ARM |
| `yarn electron:build --linux deb:armv7l` | Debian armv7l(树莓派等) |
| `yarn electron:build --macos dir:arm64` | macOS ARM |
## :computer: 配置开发环境
本项目由 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 提供 API。
运行本项目
```shell
# 安装依赖
yarn install
# 创建本地环境变量
cp .env.example .env
# 运行(网页端)
yarn serve
# 运行(electron)
yarn electron:serve
```
本地运行 NeteaseCloudMusicApi,或者将 API [部署至 Vercel](#%EF%B8%8F-部署至-vercel)
```shell
# 运行 API (默认 3000 端口)
yarn netease_api:run
```
## ☑️ Todo
查看 Todo 请访问本项目的 [Projects](https://github.com/qier222/YesPlayMusic/projects/1)
欢迎提 Issue 和 Pull request。
## 📜 开源许可
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 灵感来源
API 源代码来自 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
- [Apple Music](https://music.apple.com)
- [YouTube Music](https://music.youtube.com)
- [Spotify](https://www.spotify.com)
- [网易云音乐](https://music.163.com)
## 🖼️ 截图
![lyrics][lyrics-screenshot]
![library-dark][library-dark-screenshot]
![album][album-screenshot]
![home-2][home-2-screenshot]
![artist][artist-screenshot]
![search][search-screenshot]
![home][home-screenshot]
![explore][explore-screenshot]
[album-screenshot]: images/album.png
[artist-screenshot]: images/artist.png
[explore-screenshot]: images/explore.png
[home-screenshot]: images/home.png
[home-2-screenshot]: images/home-2.png
[lyrics-screenshot]: images/lyrics.png
[library-screenshot]: images/library.png
[library-dark-screenshot]: images/library-dark.png
[search-screenshot]: images/search.png
================================================
FILE: babel.config.js
================================================
module.exports = {
presets: [
[
'@vue/cli-plugin-babel/preset',
{
useBuiltIns: 'usage',
shippedProposals: true,
},
],
],
};
================================================
FILE: devenv.nix
================================================
{ pkgs, lib, config, inputs, ... }:
let
nodejs16 = import inputs.nodejs16 { system = pkgs.stdenv.system; };
in
{
# https://devenv.sh/basics/
env.GREET = "devenv";
# https://devenv.sh/packages/
packages = [ pkgs.git ] ++ lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk; [
frameworks.AppKit
]);
# https://devenv.sh/languages/
languages.javascript.enable = true;
languages.javascript.package = nodejs16.pkgs.nodejs_16;
languages.javascript.corepack.enable = true;
# languages.rust.enable = true;
# https://devenv.sh/processes/
# processes.cargo-watch.exec = "cargo-watch";
# https://devenv.sh/services/
# services.postgres.enable = true;
# https://devenv.sh/scripts/
scripts.hello.exec = ''
echo hello from $GREET
'';
enterShell = ''
hello
git --version
'';
# https://devenv.sh/tasks/
# tasks = {
# "myproj:setup".exec = "mytool build";
# "devenv:enterShell".after = [ "myproj:setup" ];
# };
# https://devenv.sh/tests/
enterTest = ''
echo "Running tests"
git --version | grep --color=auto "${pkgs.git.version}"
'';
# https://devenv.sh/pre-commit-hooks/
# pre-commit.hooks.shellcheck.enable = true;
# See full reference at https://devenv.sh/reference/options/
}
================================================
FILE: devenv.yaml
================================================
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:nixos/nixpkgs/nixpkgs-unstable
nodejs16:
url: github:nixos/nixpkgs/a71323f68d4377d12c04a5410e214495ec598d4c
# https://github.com/cachix/devenv/issues/792#issuecomment-2043166453
impure: true
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend
================================================
FILE: docker/nginx.conf.example
================================================
server {
gzip on;
listen 80;
listen [::]:80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location @rewrites {
rewrite ^(.*)$ /index.html last;
}
location /api/ {
proxy_buffers 16 32k;
proxy_buffer_size 128k;
proxy_busy_buffers_size 128k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $remote_addr;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://localhost:3000/;
}
}
================================================
FILE: docker-compose.yml
================================================
services:
YesPlayMusic:
build:
context: .
image: yesplaymusic
container_name: YesPlayMusic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./docker/nginx.conf.example:/etc/nginx/conf.d/default.conf:ro
ports:
- 80:80
restart: always
depends_on:
- UnblockNeteaseMusic
environment:
- NODE_TLS_REJECT_UNAUTHORIZED=0
networks:
my_network:
UnblockNeteaseMusic:
image: pan93412/unblock-netease-music-enhanced
command: -o kugou kuwo migu bilibili pyncmd -p 80:443 -f 45.127.129.53 -e -
# environment:
# JSON_LOG: true
# LOG_LEVEL: debug
networks:
my_network:
aliases:
- music.163.com
- interface.music.163.com
- interface3.music.163.com
- interface.music.163.com.163jiasu.com
- interface3.music.163.com.163jiasu.com
restart: always
networks:
my_network:
driver: bridge
================================================
FILE: install-replit.sh
================================================
#!/usr/bin/bash
# 初始化 .replit 和 replit.nix
if [[ $1 == i ]];then
echo -e 'run = ["bash", "main.sh"]\n\nentrypoint = "main.sh"' >.replit
echo -e "{ pkgs }: {\n\t\tdeps = [\n\t\t\tpkgs.nodejs-16_x\n\t\t\tpkgs.yarn\n\t\t\tpkgs.bashInteractive\n\t\t];\n}" > replit.nix
exit
fi
# 安装
if [[ ! -d api ]];then
mkdir api
git clone https://github.com/neteasecloudmusicapienhanced/api-enhanced.git ./api && \
cd api && npm install && cd ..
fi
if [[ ! -d music ]];then
mkdir music
git clone https://github.com/qier222/YesPlayMusic ./music && \
cd music && cp .env.example .env && npm install --force && npm run build && cd ..
fi
# 启动
PID=`ps -ef | grep npm | awk '{print $2}' | sed '$d'`
if [[ ! -z ${PID} ]];then echo $PID | xargs kill;fi
nohup bash -c 'cd api && PORT=35216 node app.js' > api.log 2>&1
nohup bash -c 'npx serve music/dist/' > music.log 2>&1
================================================
FILE: jsconfig.json
================================================
{
// 支持 @ 的别名解析
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"target": "ES6",
"module": "commonjs",
"allowSyntheticDefaultImports": true,
"jsx": "preserve"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
================================================
FILE: package.json
================================================
{
"name": "yesplaymusic",
"version": "0.4.10",
"private": true,
"description": "A third party music player for Netease Music",
"author": "qier222",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"electron:build": "vue-cli-service electron:build -p never",
"electron:build-all": "vue-cli-service electron:build -p never -mwl",
"electron:build-mac": "vue-cli-service electron:build -p never -m",
"electron:build-win": "vue-cli-service electron:build -p never -w",
"electron:build-linux": "vue-cli-service electron:build -p never -l",
"electron:serve": "vue-cli-service electron:serve",
"electron:buildicon": "electron-icon-builder --input=./build/icons/icon.png --output=build --flatten",
"electron:publish": "vue-cli-service electron:build -mwl -p always",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps",
"prettier": "npx prettier --write ./src",
"netease_api:run": "npx @neteaseapireborn/api"
},
"main": "background.js",
"engines": {
"node": "14 || 16"
},
"dependencies": {
"@neteaseapireborn/api": "^4.29.7",
"@unblockneteasemusic/rust-napi": "^0.4.0",
"axios": "^0.26.1",
"change-case": "^4.1.2",
"cli-color": "^2.0.0",
"color": "^4.2.3",
"core-js": "^3.6.5",
"crypto-js": "^4.0.0",
"dayjs": "^1.8.36",
"dexie": "^3.0.3",
"discord-rich-presence": "^0.0.8",
"electron": "^13.6.7",
"electron-builder": "^23.0.0",
"electron-context-menu": "^3.1.2",
"electron-debug": "^3.1.0",
"electron-devtools-installer": "^3.2",
"electron-icon-builder": "^2.0.1",
"electron-is-dev": "^2.0.0",
"electron-log": "^4.3.0",
"electron-store": "^8.0.1",
"electron-updater": "^5.0.1",
"esbuild": "^0.20.1",
"esbuild-loader": "^4.0.3",
"express": "^4.17.1",
"express-fileupload": "^1.2.0",
"express-http-proxy": "^1.6.2",
"extract-zip": "^2.0.1",
"howler": "^2.2.3",
"js-cookie": "^2.2.1",
"jsbi": "^4.1.0",
"lodash": "^4.17.20",
"md5": "^2.3.0",
"mpris-service": "^2.1.2",
"music-metadata": "^7.5.3",
"node-vibrant": "^3.2.1-alpha.1",
"nprogress": "^0.2.0",
"pac-proxy-agent": "^4.1.0",
"plyr": "^3.6.2",
"qrcode": "^1.4.4",
"register-service-worker": "^1.7.1",
"svg-sprite-loader": "^6.0.11",
"tunnel": "^0.0.6",
"vscode-codicons": "^0.0.17",
"vue": "^2.6.11",
"vue-clipboard2": "^0.3.1",
"vue-gtag": "1",
"vue-i18n": "^8.22.0",
"vue-router": "^3.4.3",
"vue-slider-component": "^3.2.5",
"vuex": "^3.4.0",
"x11": "^2.3.0"
},
"devDependencies": {
"@types/node": "^17.0.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^7.9.0",
"husky": "^4.3.0",
"prettier": "2.5.1",
"sass": "^1.26.11",
"sass-loader": "^10.0.2",
"vue-cli-plugin-electron-builder": "~2.1.1",
"vue-template-compiler": "^2.6.11"
},
"resolutions": {
"icon-gen": "3.0.0",
"degenerator": "2.2.0",
"electron-builder": "^23.0.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true,
"browser": true
},
"extends": [
"plugin:vue/essential",
"plugin:vue/recommended",
"plugin:prettier/recommended",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"globals": {
"ipcRenderer": "off"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
],
"husky": {
"hooks": {
"pre-commit": "npm run prettier"
}
}
}
================================================
FILE: public/index.html
================================================
<%= htmlWebpackPlugin.options.title %>
================================================
FILE: public/robots.txt
================================================
User-agent: *
Disallow: /
================================================
FILE: restyled.yml
================================================
commit_template: 'style: with ${restyler.name}'
restylers:
- prettier
- prettier-json
- prettier-markdown
- prettier-yaml
- whitespace
================================================
FILE: src/App.vue
================================================
================================================
FILE: src/api/album.js
================================================
import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common';
import { cacheAlbum, getAlbumFromCache } from '@/utils/db';
/**
* 获取专辑内容
* 说明 : 调用此接口 , 传入专辑 id, 可获得专辑内容
* @param {number} id
*/
export function getAlbum(id) {
const fetchLatest = () => {
return request({
url: '/album',
method: 'get',
params: {
id,
},
}).then(data => {
cacheAlbum(id, data);
data.songs = mapTrackPlayableStatus(data.songs);
return data;
});
};
fetchLatest();
return getAlbumFromCache(id).then(result => {
return result ?? fetchLatest();
});
}
/**
* 全部新碟
* 说明 : 登录后调用此接口 ,可获取全部新碟
* - limit - 返回数量 , 默认为 30
* - offset - 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
* - area - ALL:全部,ZH:华语,EA:欧美,KR:韩国,JP:日本
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
* @param {string} params.area
*/
export function newAlbums(params) {
return request({
url: '/album/new',
method: 'get',
params,
});
}
/**
* 专辑动态信息
* 说明 : 调用此接口 , 传入专辑 id, 可获得专辑动态信息,如是否收藏,收藏数,评论数,分享数
* - id - 专辑id
* @param {number} id
*/
export function albumDynamicDetail(id) {
return request({
url: '/album/detail/dynamic',
method: 'get',
params: { id, timestamp: new Date().getTime() },
});
}
/**
* 收藏/取消收藏专辑
* 说明 : 调用此接口,可收藏/取消收藏专辑
* - id - 返专辑 id
* - t - 1 为收藏,其他为取消收藏
* @param {Object} params
* @param {number} params.id
* @param {number} params.t
*/
export function likeAAlbum(params) {
return request({
url: '/album/sub',
method: 'post',
params,
});
}
================================================
FILE: src/api/artist.js
================================================
import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common';
import { isAccountLoggedIn } from '@/utils/auth';
import { getTrackDetail } from '@/api/track';
/**
* 获取歌手单曲
* 说明 : 调用此接口 , 传入歌手 id, 可获得歌手部分信息和热门歌曲
* @param {number} id - 歌手 id, 可由搜索接口获得
*/
export function getArtist(id) {
return request({
url: '/artists',
method: 'get',
params: {
id,
timestamp: new Date().getTime(),
},
}).then(async data => {
if (!isAccountLoggedIn()) {
const trackIDs = data.hotSongs.map(t => t.id);
const tracks = await getTrackDetail(trackIDs.join(','));
data.hotSongs = tracks.songs;
return data;
}
data.hotSongs = mapTrackPlayableStatus(data.hotSongs);
return data;
});
}
/**
* 获取歌手专辑
* 说明 : 调用此接口 , 传入歌手 id, 可获得歌手专辑内容
* - id: 歌手 id
* - limit: 取出数量 , 默认为 50
* - offset: 偏移数量 , 用于分页 , 如 :( 页数 -1)*50, 其中 50 为 limit 的值 , 默认为 0
* @param {Object} params
* @param {number} params.id
* @param {number=} params.limit
* @param {number=} params.offset
*/
export function getArtistAlbum(params) {
return request({
url: '/artist/album',
method: 'get',
params,
});
}
/**
* 歌手榜
* 说明 : 调用此接口 , 可获取排行榜中的歌手榜
* - type : 地区
* 1: 华语
* 2: 欧美
* 3: 韩国
* 4: 日本
* @param {number=} type
*/
export function toplistOfArtists(type = null) {
let params = {};
if (type) {
params.type = type;
}
return request({
url: '/toplist/artist',
method: 'get',
params,
});
}
/**
* 获取歌手 mv
* 说明 : 调用此接口 , 传入歌手 id, 可获得歌手 mv 信息 , 具体 mv 播放地址可调 用/mv传入此接口获得的 mvid 来拿到 , 如 : /artist/mv?id=6452,/mv?mvid=5461064
* @param {number} params.id 歌手 id, 可由搜索接口获得
* @param {number} params.offset
* @param {number} params.limit
*/
export function artistMv(params) {
return request({
url: '/artist/mv',
method: 'get',
params,
});
}
/**
* 收藏歌手
* 说明 : 调用此接口 , 传入歌手 id, 可收藏歌手
* - id: 歌手 id
* - t: 操作,1 为收藏,其他为取消收藏
* @param {Object} params
* @param {number} params.id
* @param {number} params.t
*/
export function followAArtist(params) {
return request({
url: '/artist/sub',
method: 'post',
params,
});
}
/**
* 相似歌手
* 说明 : 调用此接口 , 传入歌手 id, 可获得相似歌手
* - id: 歌手 id
* @param {number} id
*/
export function similarArtists(id) {
return request({
url: '/simi/artist',
method: 'post',
params: { id },
});
}
================================================
FILE: src/api/auth.js
================================================
import request from '@/utils/request';
/**
* 手机登录
* - phone: 手机号码
* - password: 密码
* - countrycode: 国家码,用于国外手机号登录,例如美国传入:1
* - md5_password: md5加密后的密码,传入后 password 将失效
* @param {Object} params
* @param {string} params.phone
* @param {string} params.password
* @param {string=} params.countrycode
* @param {string=} params.md5_password
*/
export function loginWithPhone(params) {
return request({
url: '/login/cellphone',
method: 'post',
params,
});
}
/**
* 邮箱登录
* - email: 163 网易邮箱
* - password: 密码
* - md5_password: md5加密后的密码,传入后 password 将失效
* @param {Object} params
* @param {string} params.email
* @param {string} params.password
* @param {string=} params.md5_password
*/
export function loginWithEmail(params) {
return request({
url: '/login',
method: 'post',
params,
});
}
/**
* 二维码key生成接口
*/
export function loginQrCodeKey() {
return request({
url: '/login/qr/key',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
});
}
/**
* 二维码生成接口
* 说明: 调用此接口传入上一个接口生成的key可生成二维码图片的base64和二维码信息,
* 可使用base64展示图片,或者使用二维码信息内容自行使用第三方二维码生产库渲染二维码
* @param {Object} params
* @param {string} params.key
* @param {string=} params.qrimg 传入后会额外返回二维码图片base64编码
*/
export function loginQrCodeCreate(params) {
return request({
url: '/login/qr/create',
method: 'get',
params: {
...params,
timestamp: new Date().getTime(),
},
});
}
/**
* 二维码检测扫码状态接口
* 说明: 轮询此接口可获取二维码扫码状态,800为二维码过期,801为等待扫码,802为待确认,803为授权登录成功(803状态码下会返回cookies)
* @param {string} key
*/
export function loginQrCodeCheck(key) {
return request({
url: '/login/qr/check',
method: 'get',
params: {
key,
timestamp: new Date().getTime(),
},
});
}
/**
* 刷新登录
* 说明 : 调用此接口 , 可刷新登录状态
* - 调用例子 : /login/refresh
*/
export function refreshCookie() {
return request({
url: '/login/refresh',
method: 'post',
});
}
/**
* 退出登录
* 说明 : 调用此接口 , 可退出登录
*/
export function logout() {
return request({
url: '/logout',
method: 'post',
});
}
================================================
FILE: src/api/lastfm.js
================================================
// Last.fm API documents 👉 https://www.last.fm/api
import axios from 'axios';
import md5 from 'crypto-js/md5';
const apiKey = process.env.VUE_APP_LASTFM_API_KEY;
const apiSharedSecret = process.env.VUE_APP_LASTFM_API_SHARED_SECRET;
const baseUrl = window.location.origin;
const url = 'https://ws.audioscrobbler.com/2.0/';
const sign = params => {
const sortParamsKeys = Object.keys(params).sort();
const sortedParams = sortParamsKeys.reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
let signature = '';
for (const [key, value] of Object.entries(sortedParams)) {
signature += `${key}${value}`;
}
return md5(signature + apiSharedSecret).toString();
};
export function auth() {
const url = process.env.IS_ELECTRON
? `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${baseUrl}/#/lastfm/callback`
: `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${baseUrl}/lastfm/callback`;
window.open(url);
}
export function authGetSession(token) {
const signature = md5(
`api_key${apiKey}methodauth.getSessiontoken${token}${apiSharedSecret}`
).toString();
return axios({
url,
method: 'GET',
params: {
method: 'auth.getSession',
format: 'json',
api_key: apiKey,
api_sig: signature,
token,
},
});
}
export function trackUpdateNowPlaying(params) {
params.api_key = apiKey;
params.method = 'track.updateNowPlaying';
params.sk = JSON.parse(localStorage.getItem('lastfm'))['key'];
const signature = sign(params);
return axios({
url,
method: 'POST',
params: {
...params,
api_sig: signature,
format: 'json',
},
});
}
export function trackScrobble(params) {
params.api_key = apiKey;
params.method = 'track.scrobble';
params.sk = JSON.parse(localStorage.getItem('lastfm'))['key'];
const signature = sign(params);
return axios({
url,
method: 'POST',
params: {
...params,
api_sig: signature,
format: 'json',
},
});
}
================================================
FILE: src/api/mv.js
================================================
import request from '@/utils/request';
/**
* 获取 mv 数据
* 说明 : 调用此接口 , 传入 mvid ( 在搜索音乐的时候传 type=1004 获得 ) , 可获取对应 MV 数据 , 数据包含 mv 名字 , 歌手 , 发布时间 , mv 视频地址等数据 ,
* 其中 mv 视频 网易做了防盗链处理 , 可能不能直接播放 , 需要播放的话需要调用 ' mv 地址' 接口
* - 调用例子 : /mv/detail?mvid=5436712
* @param {number} mvid mv 的 id
*/
export function mvDetail(mvid) {
return request({
url: '/mv/detail',
method: 'get',
params: {
mvid,
timestamp: new Date().getTime(),
},
});
}
/**
* mv 地址
* 说明 : 调用此接口 , 传入 mv id,可获取 mv 播放地址
* - id: mv id
* - r: 分辨率,默认1080,可从 /mv/detail 接口获取分辨率列表
* - 调用例子 : /mv/url?id=5436712 /mv/url?id=10896407&r=1080
* @param {Object} params
* @param {number} params.id
* @param {number=} params.r
*/
export function mvUrl(params) {
return request({
url: '/mv/url',
method: 'get',
params,
});
}
/**
* 相似 mv
* 说明 : 调用此接口 , 传入 mvid 可获取相似 mv
* @param {number} mvid
*/
export function simiMv(mvid) {
return request({
url: '/simi/mv',
method: 'get',
params: { mvid },
});
}
/**
* 收藏/取消收藏 MV
* 说明 : 调用此接口,可收藏/取消收藏 MV
* - mvid: mv id
* - t: 1 为收藏,其他为取消收藏
* @param {Object} params
* @param {number} params.mvid
* @param {number=} params.t
*/
export function likeAMV(params) {
params.timestamp = new Date().getTime();
return request({
url: '/mv/sub',
method: 'post',
params,
});
}
================================================
FILE: src/api/others.js
================================================
import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common';
/**
* 搜索
* 说明 : 调用此接口 , 传入搜索关键词可以搜索该音乐 / 专辑 / 歌手 / 歌单 / 用户 , 关键词可以多个 , 以空格隔开 ,
* 如 " 周杰伦 搁浅 "( 不需要登录 ), 搜索获取的 mp3url 不能直接用 , 可通过 /song/url 接口传入歌曲 id 获取具体的播放链接
* - keywords : 关键词
* - limit : 返回数量 , 默认为 30
* - offset : 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
* - type: 搜索类型;默认为 1 即单曲 , 取值意义 : 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频, 1018:综合
* - 调用例子 : /search?keywords=海阔天空 /cloudsearch?keywords=海阔天空(更全)
* @param {Object} params
* @param {string} params.keywords
* @param {number=} params.limit
* @param {number=} params.offset
* @param {number=} params.type
*/
export function search(params) {
return request({
url: '/search',
method: 'get',
params,
}).then(data => {
if (data.result?.song !== undefined)
data.result.song.songs = mapTrackPlayableStatus(data.result.song.songs);
return data;
});
}
export function personalFM() {
return request({
url: '/personal_fm',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
});
}
export function fmTrash(id) {
return request({
url: '/fm_trash',
method: 'post',
params: {
timestamp: new Date().getTime(),
id,
},
});
}
================================================
FILE: src/api/playlist.js
================================================
import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common';
/**
* 推荐歌单
* 说明 : 调用此接口 , 可获取推荐歌单
* - limit: 取出数量 , 默认为 30 (不支持 offset)
* - 调用例子 : /personalized?limit=1
* @param {Object} params
* @param {number=} params.limit
*/
export function recommendPlaylist(params) {
return request({
url: '/personalized',
method: 'get',
params,
});
}
/**
* 获取每日推荐歌单
* 说明 : 调用此接口 , 可获得每日推荐歌单 ( 需要登录 )
* @param {Object} params
* @param {number=} params.limit
*/
export function dailyRecommendPlaylist(params) {
return request({
url: '/recommend/resource',
method: 'get',
params: {
params,
timestamp: Date.now(),
},
});
}
/**
* 获取歌单详情
* 说明 : 歌单能看到歌单名字, 但看不到具体歌单内容 , 调用此接口 , 传入歌单 id, 可以获取对应歌单内的所有的音乐(未登录状态只能获取不完整的歌单,登录后是完整的),
* 但是返回的trackIds是完整的,tracks 则是不完整的,可拿全部 trackIds 请求一次 song/detail 接口
* 获取所有歌曲的详情 (https://github.com/Binaryify/NeteaseCloudMusicApi/issues/452)
* - id : 歌单 id
* - s : 歌单最近的 s 个收藏者, 默认为8
* @param {number} id
* @param {boolean=} noCache
*/
export function getPlaylistDetail(id, noCache = false) {
let params = { id };
if (noCache) params.timestamp = new Date().getTime();
return request({
url: '/playlist/detail',
method: 'get',
params,
}).then(data => {
if (data.playlist) {
data.playlist.tracks = mapTrackPlayableStatus(
data.playlist.tracks,
data.privileges || []
);
}
return data;
});
}
/**
* 获取精品歌单
* 说明 : 调用此接口 , 可获取精品歌单
* - cat: tag, 比如 " 华语 "、" 古风 " 、" 欧美 "、" 流行 ", 默认为 "全部", 可从精品歌单标签列表接口获取(/playlist/highquality/tags)
* - limit: 取出歌单数量 , 默认为 20
* - before: 分页参数,取上一页最后一个歌单的 updateTime 获取下一页数据
* @param {Object} params
* @param {string} params.cat
* @param {number=} params.limit
* @param {number} params.before
*/
export function highQualityPlaylist(params) {
return request({
url: '/top/playlist/highquality',
method: 'get',
params,
});
}
/**
* 歌单 ( 网友精选碟 )
* 说明 : 调用此接口 , 可获取网友精选碟歌单
* - order: 可选值为 'new' 和 'hot', 分别对应最新和最热 , 默认为 'hot'
* - cat: tag, 比如 " 华语 "、" 古风 " 、" 欧美 "、" 流行 ", 默认为 "全部",可从歌单分类接口获取(/playlist/catlist)
* - limit: 取出歌单数量 , 默认为 50
* @param {Object} params
* @param {string} params.order
* @param {string} params.cat
* @param {number=} params.limit
*/
export function topPlaylist(params) {
return request({
url: '/top/playlist',
method: 'get',
params,
});
}
/**
* 歌单分类
* 说明 : 调用此接口,可获取歌单分类,包含 category 信息
*/
export function playlistCatlist() {
return request({
url: '/playlist/catlist',
method: 'get',
});
}
/**
* 所有榜单
* 说明 : 调用此接口,可获取所有榜单 接口地址 : /toplist
*/
export function toplists() {
return request({
url: '/toplist',
method: 'get',
});
}
/**
* 收藏/取消收藏歌单
* 说明 : 调用此接口, 传入类型和歌单 id 可收藏歌单或者取消收藏歌单
* - t : 类型,1:收藏,2:取消收藏
* - id : 歌单 id
* @param {Object} params
* @param {number} params.t
* @param {number} params.id
*/
export function subscribePlaylist(params) {
params.timestamp = new Date().getTime();
return request({
url: '/playlist/subscribe',
method: 'post',
params,
});
}
/**
* 删除歌单
* 说明 : 调用此接口 , 传入歌单id可删除歌单
* - id : 歌单id,可多个,用逗号隔开
* * @param {number} id
*/
export function deletePlaylist(id) {
return request({
url: '/playlist/delete',
method: 'post',
params: { id },
});
}
/**
* 新建歌单
* 说明 : 调用此接口 , 传入歌单名字可新建歌单
* - name : 歌单名
* - privacy : 是否设置为隐私歌单,默认否,传'10'则设置成隐私歌单
* - type : 歌单类型,默认'NORMAL',传 'VIDEO'则为视频歌单
* @param {Object} params
* @param {string} params.name
* @param {number} params.privacy
* @param {string} params.type
*/
export function createPlaylist(params) {
params.timestamp = new Date().getTime();
return request({
url: '/playlist/create',
method: 'post',
params,
});
}
/**
* 对歌单添加或删除歌曲
* 说明 : 调用此接口 , 可以添加歌曲到歌单或者从歌单删除某首歌曲 ( 需要登录 )
* - op: 从歌单增加单曲为 add, 删除为 del
* - pid: 歌单 id tracks: 歌曲 id,可多个,用逗号隔开
* @param {Object} params
* @param {string} params.op
* @param {string} params.pid
*/
export function addOrRemoveTrackFromPlaylist(params) {
params.timestamp = new Date().getTime();
return request({
url: '/playlist/tracks',
method: 'post',
params,
});
}
/**
* 每日推荐歌曲
* 说明 : 调用此接口 , 可获得每日推荐歌曲 ( 需要登录 )
* @param {Object} params
* @param {string} params.op
* @param {string} params.pid
*/
export function dailyRecommendTracks() {
return request({
url: '/recommend/songs',
method: 'get',
params: { timestamp: new Date().getTime() },
}).then(result => {
result.data.dailySongs = mapTrackPlayableStatus(
result.data.dailySongs,
result.data.privileges
);
return result;
});
}
/**
* 心动模式/智能播放
* 说明 : 登录后调用此接口 , 可获取心动模式/智能播放列表 必选参数 : id : 歌曲 id
* - id : 歌曲 id
* - pid : 歌单 id
* - sid : 要开始播放的歌曲的 id (可选参数)
* @param {Object} params
* @param {number=} params.id
* @param {number=} params.pid
*/
export function intelligencePlaylist(params) {
return request({
url: '/playmode/intelligence/list',
method: 'get',
params,
});
}
================================================
FILE: src/api/track.js
================================================
import store from '@/store';
import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common';
import {
cacheTrackDetail,
getTrackDetailFromCache,
cacheLyric,
getLyricFromCache,
} from '@/utils/db';
/**
* 获取音乐 url
* 说明 : 使用歌单详情接口后 , 能得到的音乐的 id, 但不能得到的音乐 url, 调用此接口, 传入的音乐 id( 可多个 , 用逗号隔开 ), 可以获取对应的音乐的 url,
* !!!未登录状态返回试听片段(返回字段包含被截取的正常歌曲的开始时间和结束时间)
* @param {string} id - 音乐的 id,例如 id=405998841,33894312
*/
export function getMP3(id) {
const getBr = () => {
// 当返回的 quality >= 400000时,就会优先返回 hi-res
const quality = store.state.settings?.musicQuality ?? '320000';
return quality === 'flac' ? '350000' : quality;
};
return request({
url: '/song/url',
method: 'get',
params: {
id,
br: getBr(),
},
});
}
/**
* 获取歌曲详情
* 说明 : 调用此接口 , 传入音乐 id(支持多个 id, 用 , 隔开), 可获得歌曲详情(注意:歌曲封面现在需要通过专辑内容接口获取)
* @param {string} ids - 音乐 id, 例如 ids=405998841,33894312
*/
export function getTrackDetail(ids) {
const fetchLatest = () => {
return request({
url: '/song/detail',
method: 'get',
params: {
ids,
},
}).then(data => {
data.songs.map(song => {
const privileges = data.privileges.find(t => t.id === song.id);
cacheTrackDetail(song, privileges);
});
data.songs = mapTrackPlayableStatus(data.songs, data.privileges);
return data;
});
};
fetchLatest();
let idsInArray = [String(ids)];
if (typeof ids === 'string') {
idsInArray = ids.split(',');
}
return getTrackDetailFromCache(idsInArray).then(result => {
if (result) {
result.songs = mapTrackPlayableStatus(result.songs, result.privileges);
}
return result ?? fetchLatest();
});
}
/**
* 获取歌词
* 说明 : 调用此接口 , 传入音乐 id 可获得对应音乐的歌词 ( 不需要登录 )
* @param {number} id - 音乐 id
*/
export function getLyric(id) {
const fetchLatest = () => {
return request({
url: '/lyric',
method: 'get',
params: {
id,
},
}).then(result => {
cacheLyric(id, result);
return result;
});
};
fetchLatest();
return getLyricFromCache(id).then(result => {
return result ?? fetchLatest();
});
}
/**
* 新歌速递
* 说明 : 调用此接口 , 可获取新歌速递
* @param {number} type - 地区类型 id, 对应以下: 全部:0 华语:7 欧美:96 日本:8 韩国:16
*/
export function topSong(type) {
return request({
url: '/top/song',
method: 'get',
params: {
type,
},
});
}
/**
* 喜欢音乐
* 说明 : 调用此接口 , 传入音乐 id, 可喜欢该音乐
* - id - 歌曲 id
* - like - 默认为 true 即喜欢 , 若传 false, 则取消喜欢
* @param {Object} params
* @param {number} params.id
* @param {boolean=} [params.like]
*/
export function likeATrack(params) {
params.timestamp = new Date().getTime();
return request({
url: '/like',
method: 'get',
params,
});
}
/**
* 听歌打卡
* 说明 : 调用此接口 , 传入音乐 id, 来源 id,歌曲时间 time,更新听歌排行数据
* - id - 歌曲 id
* - sourceid - 歌单或专辑 id
* - time - 歌曲播放时间,单位为秒
* @param {Object} params
* @param {number} params.id
* @param {number} params.sourceid
* @param {number=} params.time
*/
export function scrobble(params) {
params.timestamp = new Date().getTime();
return request({
url: '/scrobble',
method: 'get',
params,
});
}
================================================
FILE: src/api/user.js
================================================
import request from '@/utils/request';
/**
* 获取用户详情
* 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情
* - uid : 用户 id
* @param {number} uid
*/
export function userDetail(uid) {
return request({
url: '/user/detail',
method: 'get',
params: {
uid,
timestamp: new Date().getTime(),
},
});
}
/**
* 获取账号详情
* 说明 : 登录后调用此接口 ,可获取用户账号信息
*/
export function userAccount() {
return request({
url: '/user/account',
method: 'get',
params: {
timestamp: new Date().getTime(),
},
});
}
/**
* 获取用户歌单
* 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户歌单
* - uid : 用户 id
* - limit : 返回数量 , 默认为 30
* - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
* @param {Object} params
* @param {number} params.uid
* @param {number} params.limit
* @param {number=} params.offset
*/
export function userPlaylist(params) {
return request({
url: '/user/playlist',
method: 'get',
params,
});
}
/**
* 获取用户播放记录
* 说明 : 登录后调用此接口 , 传入用户 id, 可获取用户播放记录
* - uid : 用户 id
* - type : type=1 时只返回 weekData, type=0 时返回 allData
* @param {Object} params
* @param {number} params.uid
* @param {number} params.type
*/
export function userPlayHistory(params) {
return request({
url: '/user/record',
method: 'get',
params,
});
}
/**
* 喜欢音乐列表(需要登录)
* 说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐id列表(id数组)
* - uid: 用户 id
* @param {number} uid
*/
export function userLikedSongsIDs(uid) {
return request({
url: '/likelist',
method: 'get',
params: {
uid,
timestamp: new Date().getTime(),
},
});
}
/**
* 每日签到
* 说明 : 调用此接口可签到获取积分
* - type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到
* @param {number} type
*/
export function dailySignin(type = 0) {
return request({
url: '/daily_signin',
method: 'post',
params: {
type,
timestamp: new Date().getTime(),
},
});
}
/**
* 获取收藏的专辑(需要登录)
* 说明 : 调用此接口可获取到用户收藏的专辑
* - limit : 返回数量 , 默认为 25
* - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*25, 其中 25 为 limit 的值 , 默认为 0
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
*/
export function likedAlbums(params) {
return request({
url: '/album/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
});
}
/**
* 获取收藏的歌手(需要登录)
* 说明 : 调用此接口可获取到用户收藏的歌手
*/
export function likedArtists(params) {
return request({
url: '/artist/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
});
}
/**
* 获取收藏的MV(需要登录)
* 说明 : 调用此接口可获取到用户收藏的MV
*/
export function likedMVs(params) {
return request({
url: '/mv/sublist',
method: 'get',
params: {
limit: params.limit,
timestamp: new Date().getTime(),
},
});
}
/**
* 上传歌曲到云盘(需要登录)
*/
export function uploadSong(file) {
let formData = new FormData();
formData.append('songFile', file);
return request({
url: '/cloud',
method: 'post',
params: {
timestamp: new Date().getTime(),
},
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 200000,
}).catch(error => {
alert(`上传失败,Error: ${error}`);
});
}
/**
* 获取云盘歌曲(需要登录)
* 说明 : 登录后调用此接口 , 可获取云盘数据 , 获取的数据没有对应 url, 需要再调用一 次 /song/url 获取 url
* - limit : 返回数量 , 默认为 200
* - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*200, 其中 200 为 limit 的值 , 默认为 0
* @param {Object} params
* @param {number} params.limit
* @param {number=} params.offset
*/
export function cloudDisk(params = {}) {
params.timestamp = new Date().getTime();
return request({
url: '/user/cloud',
method: 'get',
params,
});
}
/**
* 获取云盘歌曲详情(需要登录)
*/
export function cloudDiskTrackDetail(id) {
return request({
url: '/user/cloud/detail',
method: 'get',
params: {
timestamp: new Date().getTime(),
id,
},
});
}
/**
* 删除云盘歌曲(需要登录)
* @param {Array} id
*/
export function cloudDiskTrackDelete(id) {
return request({
url: '/user/cloud/del',
method: 'get',
params: {
timestamp: new Date().getTime(),
id,
},
});
}
================================================
FILE: src/assets/css/global.scss
================================================
@font-face {
font-family: 'Barlow';
font-weight: normal;
src: url('~@/assets/fonts/Barlow-Regular.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-weight: medium;
src: url('~@/assets/fonts/Barlow-Medium.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-Medium.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-weight: 600;
src: url('~@/assets/fonts/Barlow-SemiBold.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-SemiBold.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-weight: bold;
src: url('~@/assets/fonts/Barlow-Bold.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-weight: 800;
src: url('~@/assets/fonts/Barlow-ExtraBold.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-ExtraBold.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-weight: 900;
src: url('~@/assets/fonts/Barlow-Black.woff2') format('woff2'),
url('~@/assets/fonts/Barlow-Black.ttf') format('truetype');
}
:root {
--color-body-bg: #ffffff;
--color-text: #000;
--color-primary: #335eea;
--color-primary-bg: #eaeffd;
--color-secondary: #7a7a7b;
--color-secondary-bg: #f5f5f7;
--color-navbar-bg: rgba(255, 255, 255, 0.86);
--color-primary-bg-for-transparent: rgba(189, 207, 255, 0.28);
--color-secondary-bg-for-transparent: rgba(209, 209, 214, 0.28);
--html-overflow-y: overlay;
}
[data-theme='dark'] {
--color-body-bg: #222222;
--color-text: #ffffff;
--color-primary: #335eea;
--color-primary-bg: #bbcdff;
--color-secondary: #7a7a7b;
--color-secondary-bg: #323232;
--color-navbar-bg: rgba(34, 34, 34, 0.86);
--color-primary-bg-for-transparent: rgba(255, 255, 255, 0.12);
--color-secondary-bg-for-transparent: rgba(255, 255, 255, 0.08);
}
#app,
input {
font-family: 'Barlow', ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei,
Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif,
microsoft uighur;
}
body {
background-color: var(--color-body-bg);
}
html {
overflow-y: var(--html-overflow-y);
min-width: 768px;
overscroll-behavior: none;
}
select,
button {
font-family: inherit;
}
button {
background: none;
border: none;
cursor: pointer;
user-select: none;
}
input,
button {
&:focus {
outline: none;
}
}
a {
color: inherit;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
[data-electron='yes'] {
button,
.navigation-links a,
.playlist-info .description {
cursor: default !important;
}
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-left: 1px solid rgba(128, 128, 128, 0.18);
background: var(--color-body-bg);
}
::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
background: rgba(128, 128, 128, 0.38);
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: var(--color-secondary-bg);
}
.user-select-none {
user-select: none;
}
================================================
FILE: src/assets/css/nprogress.css
================================================
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: #335eea;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #335eea, 0 0 5px #335eea;
opacity: 1;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
================================================
FILE: src/assets/css/plyr.css
================================================
@keyframes plyr-progress {
to {
background-position: 25px 0;
background-position: var(--plyr-progress-loading-size, 25px) 0;
}
}
@keyframes plyr-popup {
0% {
opacity: 0.5;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes plyr-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.plyr {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
align-items: center;
direction: ltr;
display: flex;
flex-direction: column;
font-family: inherit;
font-family: var(--plyr-font-family, inherit);
font-variant-numeric: tabular-nums;
font-weight: 400;
font-weight: var(--plyr-font-weight-regular, 400);
height: 100%;
line-height: 1.7;
line-height: var(--plyr-line-height, 1.7);
max-width: 100%;
min-width: 200px;
position: relative;
text-shadow: none;
transition: box-shadow 0.3s ease;
z-index: 0;
}
.plyr audio,
.plyr iframe,
.plyr video {
display: block;
height: 100%;
width: 100%;
}
.plyr button {
font: inherit;
line-height: inherit;
width: auto;
}
.plyr:focus {
outline: 0;
}
.plyr--full-ui {
box-sizing: border-box;
}
.plyr--full-ui *,
.plyr--full-ui ::after,
.plyr--full-ui ::before {
box-sizing: inherit;
}
.plyr--full-ui a,
.plyr--full-ui button,
.plyr--full-ui input,
.plyr--full-ui label {
touch-action: manipulation;
}
.plyr__badge {
background: #4a5464;
background: var(--plyr-badge-background, #4a5464);
border-radius: 2px;
border-radius: var(--plyr-badge-border-radius, 2px);
color: #fff;
color: var(--plyr-badge-text-color, #fff);
font-size: 9px;
font-size: var(--plyr-font-size-badge, 9px);
line-height: 1;
padding: 3px 4px;
}
.plyr--full-ui ::-webkit-media-text-track-container {
display: none;
}
.plyr__captions {
animation: plyr-fade-in 0.3s ease;
bottom: 0;
display: none;
font-size: 13px;
font-size: var(--plyr-font-size-small, 13px);
left: 0;
padding: 10px;
padding: var(--plyr-control-spacing, 10px);
position: absolute;
text-align: center;
transition: transform 0.4s ease-in-out;
width: 100%;
}
.plyr__captions span:empty {
display: none;
}
@media (min-width: 480px) {
.plyr__captions {
font-size: 15px;
font-size: var(--plyr-font-size-base, 15px);
padding: calc(10px * 2);
padding: calc(var(--plyr-control-spacing, 10px) * 2);
}
}
@media (min-width: 768px) {
.plyr__captions {
font-size: 18px;
font-size: var(--plyr-font-size-large, 18px);
}
}
.plyr--captions-active .plyr__captions {
display: block;
}
.plyr:not(.plyr--hide-controls) .plyr__controls:not(:empty) ~ .plyr__captions {
transform: translateY(calc(10px * -4));
transform: translateY(calc(var(--plyr-control-spacing, 10px) * -4));
}
.plyr__caption {
background: rgba(0, 0, 0, 0.8);
background: var(--plyr-captions-background, rgba(0, 0, 0, 0.8));
border-radius: 2px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
color: #fff;
color: var(--plyr-captions-text-color, #fff);
line-height: 185%;
padding: 0.2em 0.5em;
white-space: pre-wrap;
}
.plyr__caption div {
display: inline;
}
.plyr__control {
background: 0 0;
border: 0;
border-radius: 3px;
border-radius: var(--plyr-control-radius, 3px);
color: inherit;
cursor: pointer;
flex-shrink: 0;
overflow: visible;
padding: calc(10px * 0.7);
padding: calc(var(--plyr-control-spacing, 10px) * 0.7);
position: relative;
transition: all 0.3s ease;
}
.plyr__control svg {
display: block;
fill: currentColor;
height: 18px;
height: var(--plyr-control-icon-size, 18px);
pointer-events: none;
width: 18px;
width: var(--plyr-control-icon-size, 18px);
}
.plyr__control:focus {
outline: 0;
}
.plyr__control.plyr__tab-focus {
outline-color: #00b3ff;
outline-color: var(
--plyr-tab-focus-color,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
outline-offset: 2px;
outline-style: dotted;
outline-width: 3px;
}
a.plyr__control {
text-decoration: none;
}
a.plyr__control::after,
a.plyr__control::before {
display: none;
}
.plyr__control.plyr__control--pressed .icon--not-pressed,
.plyr__control.plyr__control--pressed .label--not-pressed,
.plyr__control:not(.plyr__control--pressed) .icon--pressed,
.plyr__control:not(.plyr__control--pressed) .label--pressed {
display: none;
}
.plyr--full-ui ::-webkit-media-controls {
display: none;
}
.plyr__controls {
align-items: center;
display: flex;
justify-content: flex-end;
text-align: center;
}
.plyr__controls .plyr__progress__container {
flex: 1;
min-width: 0;
}
.plyr__controls .plyr__controls__item {
margin-left: calc(10px / 4);
margin-left: calc(var(--plyr-control-spacing, 10px) / 4);
}
.plyr__controls .plyr__controls__item:first-child {
margin-left: 0;
margin-right: auto;
}
.plyr__controls .plyr__controls__item.plyr__progress__container {
padding-left: calc(10px / 4);
padding-left: calc(var(--plyr-control-spacing, 10px) / 4);
}
.plyr__controls .plyr__controls__item.plyr__time {
padding: 0 calc(10px / 2);
padding: 0 calc(var(--plyr-control-spacing, 10px) / 2);
}
.plyr__controls .plyr__controls__item.plyr__progress__container:first-child,
.plyr__controls .plyr__controls__item.plyr__time + .plyr__time,
.plyr__controls .plyr__controls__item.plyr__time:first-child {
padding-left: 0;
}
.plyr__controls:empty {
display: none;
}
.plyr [data-plyr='airplay'],
.plyr [data-plyr='captions'],
.plyr [data-plyr='fullscreen'],
.plyr [data-plyr='pip'] {
display: none;
}
.plyr--airplay-supported [data-plyr='airplay'],
.plyr--captions-enabled [data-plyr='captions'],
.plyr--fullscreen-enabled [data-plyr='fullscreen'],
.plyr--pip-supported [data-plyr='pip'] {
display: inline-block;
}
.plyr__menu {
display: flex;
position: relative;
}
.plyr__menu .plyr__control svg {
transition: transform 0.3s ease;
}
.plyr__menu .plyr__control[aria-expanded='true'] svg {
transform: rotate(90deg);
}
.plyr__menu .plyr__control[aria-expanded='true'] .plyr__tooltip {
display: none;
}
.plyr__menu__container {
animation: plyr-popup 0.2s ease;
background: rgba(255, 255, 255, 0.9);
background: var(--plyr-menu-background, rgba(255, 255, 255, 0.9));
border-radius: 8px;
bottom: 100%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
box-shadow: var(--plyr-menu-shadow, 0 1px 2px rgba(0, 0, 0, 0.15));
color: #4a5464;
color: var(--plyr-menu-color, #4a5464);
font-size: 15px;
font-size: var(--plyr-font-size-base, 15px);
margin-bottom: 10px;
position: absolute;
right: -3px;
text-align: left;
white-space: nowrap;
z-index: 3;
}
.plyr__menu__container > div {
overflow: hidden;
transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.plyr__menu__container::after {
border: 4px solid transparent;
border: var(--plyr-menu-arrow-size, 4px) solid transparent;
border-top-color: rgba(255, 255, 255, 0.9);
border-top-color: var(--plyr-menu-background, rgba(255, 255, 255, 0.9));
content: '';
height: 0;
position: absolute;
right: calc(((18px / 2) + calc(10px * 0.7)) - (4px / 2));
right: calc(
(
(var(--plyr-control-icon-size, 18px) / 2) +
calc(var(--plyr-control-spacing, 10px) * 0.7)
) - (var(--plyr-menu-arrow-size, 4px) / 2)
);
top: 100%;
width: 0;
}
.plyr__menu__container [role='menu'] {
padding: calc(10px * 0.7);
padding: calc(var(--plyr-control-spacing, 10px) * 0.7);
}
.plyr__menu__container [role='menuitem'],
.plyr__menu__container [role='menuitemradio'] {
margin-top: 2px;
}
.plyr__menu__container [role='menuitem']:first-child,
.plyr__menu__container [role='menuitemradio']:first-child {
margin-top: 0;
}
.plyr__menu__container .plyr__control {
align-items: center;
color: #4a5464;
color: var(--plyr-menu-color, #4a5464);
display: flex;
font-size: 13px;
font-size: var(--plyr-font-size-menu, var(--plyr-font-size-small, 13px));
padding-bottom: calc(calc(10px * 0.7) / 1.5);
padding-bottom: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) / 1.5);
padding-left: calc(calc(10px * 0.7) * 1.5);
padding-left: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) * 1.5);
padding-right: calc(calc(10px * 0.7) * 1.5);
padding-right: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) * 1.5);
padding-top: calc(calc(10px * 0.7) / 1.5);
padding-top: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) / 1.5);
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
width: 100%;
}
.plyr__menu__container .plyr__control > span {
align-items: inherit;
display: flex;
width: 100%;
}
.plyr__menu__container .plyr__control::after {
border: 4px solid transparent;
border: var(--plyr-menu-item-arrow-size, 4px) solid transparent;
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.plyr__menu__container .plyr__control--forward {
padding-right: calc(calc(10px * 0.7) * 4);
padding-right: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) * 4);
}
.plyr__menu__container .plyr__control--forward::after {
border-left-color: #728197;
border-left-color: var(--plyr-menu-arrow-color, #728197);
right: calc((calc(10px * 0.7) * 1.5) - 4px);
right: calc(
(calc(var(--plyr-control-spacing, 10px) * 0.7) * 1.5) -
var(--plyr-menu-item-arrow-size, 4px)
);
}
.plyr__menu__container .plyr__control--forward.plyr__tab-focus::after,
.plyr__menu__container .plyr__control--forward:hover::after {
border-left-color: currentColor;
}
.plyr__menu__container .plyr__control--back {
font-weight: 400;
font-weight: var(--plyr-font-weight-regular, 400);
margin: calc(10px * 0.7);
margin: calc(var(--plyr-control-spacing, 10px) * 0.7);
margin-bottom: calc(calc(10px * 0.7) / 2);
margin-bottom: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) / 2);
padding-left: calc(calc(10px * 0.7) * 4);
padding-left: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) * 4);
position: relative;
width: calc(100% - (calc(10px * 0.7) * 2));
width: calc(100% - (calc(var(--plyr-control-spacing, 10px) * 0.7) * 2));
}
.plyr__menu__container .plyr__control--back::after {
border-right-color: #728197;
border-right-color: var(--plyr-menu-arrow-color, #728197);
left: calc((calc(10px * 0.7) * 1.5) - 4px);
left: calc(
(calc(var(--plyr-control-spacing, 10px) * 0.7) * 1.5) -
var(--plyr-menu-item-arrow-size, 4px)
);
}
.plyr__menu__container .plyr__control--back::before {
background: #dcdfe5;
background: var(--plyr-menu-back-border-color, #dcdfe5);
box-shadow: 0 1px 0 #fff;
box-shadow: 0 1px 0 var(--plyr-menu-back-border-shadow-color, #fff);
content: '';
height: 1px;
left: 0;
margin-top: calc(calc(10px * 0.7) / 2);
margin-top: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) / 2);
overflow: hidden;
position: absolute;
right: 0;
top: 100%;
}
.plyr__menu__container .plyr__control--back.plyr__tab-focus::after,
.plyr__menu__container .plyr__control--back:hover::after {
border-right-color: currentColor;
}
.plyr__menu__container .plyr__control[role='menuitemradio'] {
padding-left: calc(10px * 0.7);
padding-left: calc(var(--plyr-control-spacing, 10px) * 0.7);
}
.plyr__menu__container .plyr__control[role='menuitemradio']::after,
.plyr__menu__container .plyr__control[role='menuitemradio']::before {
border-radius: 100%;
}
.plyr__menu__container .plyr__control[role='menuitemradio']::before {
background: rgba(0, 0, 0, 0.1);
content: '';
display: block;
flex-shrink: 0;
height: 16px;
margin-right: 10px;
margin-right: var(--plyr-control-spacing, 10px);
transition: all 0.3s ease;
width: 16px;
}
.plyr__menu__container .plyr__control[role='menuitemradio']::after {
background: #fff;
border: 0;
height: 6px;
left: 12px;
opacity: 0;
top: 50%;
transform: translateY(-50%) scale(0);
transition: transform 0.3s ease, opacity 0.3s ease;
width: 6px;
}
.plyr__menu__container
.plyr__control[role='menuitemradio'][aria-checked='true']::before {
background: #00b3ff;
background: var(
--plyr-control-toggle-checked-background,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
}
.plyr__menu__container
.plyr__control[role='menuitemradio'][aria-checked='true']::after {
opacity: 1;
transform: translateY(-50%) scale(1);
}
.plyr__menu__container
.plyr__control[role='menuitemradio'].plyr__tab-focus::before,
.plyr__menu__container .plyr__control[role='menuitemradio']:hover::before {
background: rgba(35, 40, 47, 0.1);
}
.plyr__menu__container .plyr__menu__value {
align-items: center;
display: flex;
margin-left: auto;
margin-right: calc((calc(10px * 0.7) - 2) * -1);
margin-right: calc((calc(var(--plyr-control-spacing, 10px) * 0.7) - 2) * -1);
overflow: hidden;
padding-left: calc(calc(10px * 0.7) * 3.5);
padding-left: calc(calc(var(--plyr-control-spacing, 10px) * 0.7) * 3.5);
pointer-events: none;
}
.plyr--full-ui input[type='range'] {
-webkit-appearance: none;
background: 0 0;
border: 0;
border-radius: calc(13px * 2);
border-radius: calc(var(--plyr-range-thumb-height, 13px) * 2);
color: #00b3ff;
color: var(
--plyr-range-fill-background,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
display: block;
height: calc((3px * 2) + 13px);
height: calc(
(var(--plyr-range-thumb-active-shadow-width, 3px) * 2) +
var(--plyr-range-thumb-height, 13px)
);
margin: 0;
padding: 0;
transition: box-shadow 0.3s ease;
width: 100%;
}
.plyr--full-ui input[type='range']::-webkit-slider-runnable-track {
background: 0 0;
border: 0;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
-webkit-transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease;
-webkit-user-select: none;
user-select: none;
background-image: linear-gradient(to right, currentColor 0, transparent 0);
background-image: linear-gradient(
to right,
currentColor var(--value, 0),
transparent var(--value, 0)
);
}
.plyr--full-ui input[type='range']::-webkit-slider-thumb {
background: #fff;
background: var(--plyr-range-thumb-background, #fff);
border: 0;
border-radius: 50%;
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
);
height: 13px;
height: var(--plyr-range-thumb-height, 13px);
position: relative;
-webkit-transition: all 0.2s ease;
transition: all 0.2s ease;
width: 13px;
width: var(--plyr-range-thumb-height, 13px);
-webkit-appearance: none;
margin-top: calc(((13px - 5px) / 2) * -1);
margin-top: calc(
(
(
var(--plyr-range-thumb-height, 13px) -
var(--plyr-range-track-height, 5px)
) / 2
) * -1
);
}
.plyr--full-ui input[type='range']::-moz-range-track {
background: 0 0;
border: 0;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
-moz-transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease;
user-select: none;
}
.plyr--full-ui input[type='range']::-moz-range-thumb {
background: #fff;
background: var(--plyr-range-thumb-background, #fff);
border: 0;
border-radius: 50%;
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
);
height: 13px;
height: var(--plyr-range-thumb-height, 13px);
position: relative;
-moz-transition: all 0.2s ease;
transition: all 0.2s ease;
width: 13px;
width: var(--plyr-range-thumb-height, 13px);
}
.plyr--full-ui input[type='range']::-moz-range-progress {
background: currentColor;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
}
.plyr--full-ui input[type='range']::-ms-track {
background: 0 0;
border: 0;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
-ms-transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease;
-ms-user-select: none;
user-select: none;
color: transparent;
}
.plyr--full-ui input[type='range']::-ms-fill-upper {
background: 0 0;
border: 0;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
-ms-transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease;
-ms-user-select: none;
user-select: none;
}
.plyr--full-ui input[type='range']::-ms-fill-lower {
background: 0 0;
border: 0;
border-radius: calc(5px / 2);
border-radius: calc(var(--plyr-range-track-height, 5px) / 2);
height: 5px;
height: var(--plyr-range-track-height, 5px);
-ms-transition: box-shadow 0.3s ease;
transition: box-shadow 0.3s ease;
-ms-user-select: none;
user-select: none;
background: currentColor;
}
.plyr--full-ui input[type='range']::-ms-thumb {
background: #fff;
background: var(--plyr-range-thumb-background, #fff);
border: 0;
border-radius: 50%;
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
);
height: 13px;
height: var(--plyr-range-thumb-height, 13px);
position: relative;
-ms-transition: all 0.2s ease;
transition: all 0.2s ease;
width: 13px;
width: var(--plyr-range-thumb-height, 13px);
margin-top: 0;
}
.plyr--full-ui input[type='range']::-ms-tooltip {
display: none;
}
.plyr--full-ui input[type='range']:focus {
outline: 0;
}
.plyr--full-ui input[type='range']::-moz-focus-outer {
border: 0;
}
.plyr--full-ui
input[type='range'].plyr__tab-focus::-webkit-slider-runnable-track {
outline-color: #00b3ff;
outline-color: var(
--plyr-tab-focus-color,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
outline-offset: 2px;
outline-style: dotted;
outline-width: 3px;
}
.plyr--full-ui input[type='range'].plyr__tab-focus::-moz-range-track {
outline-color: #00b3ff;
outline-color: var(
--plyr-tab-focus-color,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
outline-offset: 2px;
outline-style: dotted;
outline-width: 3px;
}
.plyr--full-ui input[type='range'].plyr__tab-focus::-ms-track {
outline-color: #00b3ff;
outline-color: var(
--plyr-tab-focus-color,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
outline-offset: 2px;
outline-style: dotted;
outline-width: 3px;
}
.plyr__poster {
background-color: #000;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: contain;
height: 100%;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transition: opacity 0.2s ease;
width: 100%;
z-index: 1;
}
.plyr--stopped.plyr__poster-enabled .plyr__poster {
opacity: 1;
}
.plyr__time {
font-size: 13px;
font-size: var(--plyr-font-size-time, var(--plyr-font-size-small, 13px));
}
.plyr__time + .plyr__time::before {
content: '\2044';
margin-right: 10px;
margin-right: var(--plyr-control-spacing, 10px);
}
@media (max-width: calc(768px - 1)) {
.plyr__time + .plyr__time {
display: none;
}
}
.plyr__tooltip {
background: rgba(255, 255, 255, 0.9);
background: var(--plyr-tooltip-background, rgba(255, 255, 255, 0.9));
border-radius: 3px;
border-radius: var(--plyr-tooltip-radius, 3px);
bottom: 100%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
box-shadow: var(--plyr-tooltip-shadow, 0 1px 2px rgba(0, 0, 0, 0.15));
color: #4a5464;
color: var(--plyr-tooltip-color, #4a5464);
font-size: 13px;
font-size: var(--plyr-font-size-small, 13px);
font-weight: 400;
font-weight: var(--plyr-font-weight-regular, 400);
left: 50%;
line-height: 1.3;
margin-bottom: calc(calc(10px / 2) * 2);
margin-bottom: calc(calc(var(--plyr-control-spacing, 10px) / 2) * 2);
opacity: 0;
padding: calc(10px / 2) calc(calc(10px / 2) * 1.5);
padding: calc(var(--plyr-control-spacing, 10px) / 2)
calc(calc(var(--plyr-control-spacing, 10px) / 2) * 1.5);
pointer-events: none;
position: absolute;
transform: translate(-50%, 10px) scale(0.8);
transform-origin: 50% 100%;
transition: transform 0.2s 0.1s ease, opacity 0.2s 0.1s ease;
white-space: nowrap;
z-index: 2;
}
.plyr__tooltip::before {
border-left: 4px solid transparent;
border-left: var(--plyr-tooltip-arrow-size, 4px) solid transparent;
border-right: 4px solid transparent;
border-right: var(--plyr-tooltip-arrow-size, 4px) solid transparent;
border-top: 4px solid rgba(255, 255, 255, 0.9);
border-top: var(--plyr-tooltip-arrow-size, 4px) solid
var(--plyr-tooltip-background, rgba(255, 255, 255, 0.9));
bottom: calc(4px * -1);
bottom: calc(var(--plyr-tooltip-arrow-size, 4px) * -1);
content: '';
height: 0;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 0;
z-index: 2;
}
.plyr .plyr__control.plyr__tab-focus .plyr__tooltip,
.plyr .plyr__control:hover .plyr__tooltip,
.plyr__tooltip--visible {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.plyr .plyr__control:hover .plyr__tooltip {
z-index: 3;
}
.plyr__controls > .plyr__control:first-child .plyr__tooltip,
.plyr__controls > .plyr__control:first-child + .plyr__control .plyr__tooltip {
left: 0;
transform: translate(0, 10px) scale(0.8);
transform-origin: 0 100%;
}
.plyr__controls > .plyr__control:first-child .plyr__tooltip::before,
.plyr__controls
> .plyr__control:first-child
+ .plyr__control
.plyr__tooltip::before {
left: calc((18px / 2) + calc(10px * 0.7));
left: calc(
(var(--plyr-control-icon-size, 18px) / 2) +
calc(var(--plyr-control-spacing, 10px) * 0.7)
);
}
.plyr__controls > .plyr__control:last-child .plyr__tooltip {
left: auto;
right: 0;
transform: translate(0, 10px) scale(0.8);
transform-origin: 100% 100%;
}
.plyr__controls > .plyr__control:last-child .plyr__tooltip::before {
left: auto;
right: calc((18px / 2) + calc(10px * 0.7));
right: calc(
(var(--plyr-control-icon-size, 18px) / 2) +
calc(var(--plyr-control-spacing, 10px) * 0.7)
);
transform: translateX(50%);
}
.plyr__controls > .plyr__control:first-child .plyr__tooltip--visible,
.plyr__controls
> .plyr__control:first-child
+ .plyr__control
.plyr__tooltip--visible,
.plyr__controls
> .plyr__control:first-child
+ .plyr__control.plyr__tab-focus
.plyr__tooltip,
.plyr__controls
> .plyr__control:first-child
+ .plyr__control:hover
.plyr__tooltip,
.plyr__controls > .plyr__control:first-child.plyr__tab-focus .plyr__tooltip,
.plyr__controls > .plyr__control:first-child:hover .plyr__tooltip,
.plyr__controls > .plyr__control:last-child .plyr__tooltip--visible,
.plyr__controls > .plyr__control:last-child.plyr__tab-focus .plyr__tooltip,
.plyr__controls > .plyr__control:last-child:hover .plyr__tooltip {
transform: translate(0, 0) scale(1);
}
.plyr__progress {
left: calc(13px * 0.5);
left: calc(var(--plyr-range-thumb-height, 13px) * 0.5);
margin-right: 13px;
margin-right: var(--plyr-range-thumb-height, 13px);
position: relative;
}
.plyr__progress input[type='range'],
.plyr__progress__buffer {
margin-left: calc(13px * -0.5);
margin-left: calc(var(--plyr-range-thumb-height, 13px) * -0.5);
margin-right: calc(13px * -0.5);
margin-right: calc(var(--plyr-range-thumb-height, 13px) * -0.5);
width: calc(100% + 13px);
width: calc(100% + var(--plyr-range-thumb-height, 13px));
}
.plyr__progress input[type='range'] {
position: relative;
z-index: 2;
}
.plyr__progress .plyr__tooltip {
font-size: 13px;
font-size: var(--plyr-font-size-time, var(--plyr-font-size-small, 13px));
left: 0;
}
.plyr__progress__buffer {
-webkit-appearance: none;
background: 0 0;
border: 0;
border-radius: 100px;
height: 5px;
height: var(--plyr-range-track-height, 5px);
left: 0;
margin-top: calc((5px / 2) * -1);
margin-top: calc((var(--plyr-range-track-height, 5px) / 2) * -1);
padding: 0;
position: absolute;
top: 50%;
}
.plyr__progress__buffer::-webkit-progress-bar {
background: 0 0;
}
.plyr__progress__buffer::-webkit-progress-value {
background: currentColor;
border-radius: 100px;
min-width: 5px;
min-width: var(--plyr-range-track-height, 5px);
-webkit-transition: width 0.2s ease;
transition: width 0.2s ease;
}
.plyr__progress__buffer::-moz-progress-bar {
background: currentColor;
border-radius: 100px;
min-width: 5px;
min-width: var(--plyr-range-track-height, 5px);
-moz-transition: width 0.2s ease;
transition: width 0.2s ease;
}
.plyr__progress__buffer::-ms-fill {
border-radius: 100px;
-ms-transition: width 0.2s ease;
transition: width 0.2s ease;
}
.plyr--loading .plyr__progress__buffer {
animation: plyr-progress 1s linear infinite;
background-image: linear-gradient(
-45deg,
rgba(35, 40, 47, 0.6) 25%,
transparent 25%,
transparent 50%,
rgba(35, 40, 47, 0.6) 50%,
rgba(35, 40, 47, 0.6) 75%,
transparent 75%,
transparent
);
background-image: linear-gradient(
-45deg,
var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 25%,
transparent 25%,
transparent 50%,
var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 50%,
var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 75%,
transparent 75%,
transparent
);
background-repeat: repeat-x;
background-size: 25px 25px;
background-size: var(--plyr-progress-loading-size, 25px)
var(--plyr-progress-loading-size, 25px);
color: transparent;
}
.plyr--video.plyr--loading .plyr__progress__buffer {
background-color: rgba(255, 255, 255, 0.25);
background-color: var(
--plyr-video-progress-buffered-background,
rgba(255, 255, 255, 0.25)
);
}
.plyr--audio.plyr--loading .plyr__progress__buffer {
background-color: rgba(193, 200, 209, 0.6);
background-color: var(
--plyr-audio-progress-buffered-background,
rgba(193, 200, 209, 0.6)
);
}
.plyr__volume {
align-items: center;
display: flex;
max-width: 110px;
min-width: 80px;
position: relative;
width: 20%;
}
.plyr__volume input[type='range'] {
margin-left: calc(10px / 2);
margin-left: calc(var(--plyr-control-spacing, 10px) / 2);
margin-right: calc(10px / 2);
margin-right: calc(var(--plyr-control-spacing, 10px) / 2);
position: relative;
z-index: 2;
}
.plyr--is-ios .plyr__volume {
min-width: 0;
width: auto;
}
.plyr--audio {
display: block;
}
.plyr--audio .plyr__controls {
background: #fff;
background: var(--plyr-audio-controls-background, #fff);
border-radius: inherit;
color: #4a5464;
color: var(--plyr-audio-control-color, #4a5464);
padding: 10px;
padding: var(--plyr-control-spacing, 10px);
}
.plyr--audio .plyr__control.plyr__tab-focus,
.plyr--audio .plyr__control:hover,
.plyr--audio .plyr__control[aria-expanded='true'] {
background: #00b3ff;
background: var(
--plyr-audio-control-background-hover,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
color: #fff;
color: var(--plyr-audio-control-color-hover, #fff);
}
.plyr--full-ui.plyr--audio input[type='range']::-webkit-slider-runnable-track {
background-color: rgba(193, 200, 209, 0.6);
background-color: var(
--plyr-audio-range-track-background,
var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6))
);
}
.plyr--full-ui.plyr--audio input[type='range']::-moz-range-track {
background-color: rgba(193, 200, 209, 0.6);
background-color: var(
--plyr-audio-range-track-background,
var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6))
);
}
.plyr--full-ui.plyr--audio input[type='range']::-ms-track {
background-color: rgba(193, 200, 209, 0.6);
background-color: var(
--plyr-audio-range-track-background,
var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6))
);
}
.plyr--full-ui.plyr--audio input[type='range']:active::-webkit-slider-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(35, 40, 47, 0.1);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1));
}
.plyr--full-ui.plyr--audio input[type='range']:active::-moz-range-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(35, 40, 47, 0.1);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1));
}
.plyr--full-ui.plyr--audio input[type='range']:active::-ms-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(35, 40, 47, 0.1);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1));
}
.plyr--audio .plyr__progress__buffer {
color: rgba(193, 200, 209, 0.6);
color: var(
--plyr-audio-progress-buffered-background,
rgba(193, 200, 209, 0.6)
);
}
.plyr--video {
overflow: hidden;
}
.plyr--video.plyr--menu-open {
overflow: visible;
}
.plyr__video-wrapper {
height: 100%;
margin: auto;
overflow: hidden;
position: relative;
width: 100%;
}
.plyr__video-embed,
.plyr__video-wrapper--fixed-ratio {
height: 0;
padding-bottom: 56.25%;
}
.plyr__video-embed iframe,
.plyr__video-wrapper--fixed-ratio video {
border: 0;
left: 0;
position: absolute;
top: 0;
}
.plyr--full-ui .plyr__video-embed > .plyr__video-embed__container {
padding-bottom: 240%;
position: relative;
transform: translateY(-38.28125%);
}
.plyr--video .plyr__controls {
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.75));
background: var(
--plyr-video-controls-background,
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.75))
);
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
bottom: 0;
color: #fff;
color: var(--plyr-video-control-color, #fff);
left: 0;
padding: calc(10px / 2);
padding: calc(var(--plyr-control-spacing, 10px) / 2);
padding-top: calc(10px * 2);
padding-top: calc(var(--plyr-control-spacing, 10px) * 2);
position: absolute;
right: 0;
transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out;
z-index: 3;
}
@media (min-width: 480px) {
.plyr--video .plyr__controls {
padding: 10px;
padding: var(--plyr-control-spacing, 10px);
padding-top: calc(10px * 3.5);
padding-top: calc(var(--plyr-control-spacing, 10px) * 3.5);
}
}
.plyr--video.plyr--hide-controls .plyr__controls {
opacity: 0;
pointer-events: none;
transform: translateY(100%);
}
.plyr--video .plyr__control.plyr__tab-focus,
.plyr--video .plyr__control:hover,
.plyr--video .plyr__control[aria-expanded='true'] {
background: #00b3ff;
background: var(
--plyr-video-control-background-hover,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
color: #fff;
color: var(--plyr-video-control-color-hover, #fff);
}
.plyr__control--overlaid {
background: #00b3ff;
background: var(
--plyr-video-control-background-hover,
var(--plyr-color-main, var(--plyr-color-main, #00b3ff))
);
border: 0;
border-radius: 100%;
color: #fff;
color: var(--plyr-video-control-color, #fff);
display: none;
left: 50%;
opacity: 0.9;
padding: calc(10px * 1.5);
padding: calc(var(--plyr-control-spacing, 10px) * 1.5);
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
transition: 0.3s;
z-index: 2;
}
.plyr__control--overlaid svg {
left: 2px;
position: relative;
}
.plyr__control--overlaid:focus,
.plyr__control--overlaid:hover {
opacity: 1;
}
.plyr--playing .plyr__control--overlaid {
opacity: 0;
visibility: hidden;
}
.plyr--full-ui.plyr--video .plyr__control--overlaid {
display: block;
}
.plyr--full-ui.plyr--video input[type='range']::-webkit-slider-runnable-track {
background-color: rgba(255, 255, 255, 0.25);
background-color: var(
--plyr-video-range-track-background,
var(--plyr-video-progress-buffered-background, rgba(255, 255, 255, 0.25))
);
}
.plyr--full-ui.plyr--video input[type='range']::-moz-range-track {
background-color: rgba(255, 255, 255, 0.25);
background-color: var(
--plyr-video-range-track-background,
var(--plyr-video-progress-buffered-background, rgba(255, 255, 255, 0.25))
);
}
.plyr--full-ui.plyr--video input[type='range']::-ms-track {
background-color: rgba(255, 255, 255, 0.25);
background-color: var(
--plyr-video-range-track-background,
var(--plyr-video-progress-buffered-background, rgba(255, 255, 255, 0.25))
);
}
.plyr--full-ui.plyr--video input[type='range']:active::-webkit-slider-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(255, 255, 255, 0.5);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(
--plyr-audio-range-thumb-active-shadow-color,
rgba(255, 255, 255, 0.5)
);
}
.plyr--full-ui.plyr--video input[type='range']:active::-moz-range-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(255, 255, 255, 0.5);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(
--plyr-audio-range-thumb-active-shadow-color,
rgba(255, 255, 255, 0.5)
);
}
.plyr--full-ui.plyr--video input[type='range']:active::-ms-thumb {
box-shadow: 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2),
0 0 0 3px rgba(255, 255, 255, 0.5);
box-shadow: var(
--plyr-range-thumb-shadow,
0 1px 1px rgba(35, 40, 47, 0.15),
0 0 0 1px rgba(35, 40, 47, 0.2)
),
0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px)
var(
--plyr-audio-range-thumb-active-shadow-color,
rgba(255, 255, 255, 0.5)
);
}
.plyr--video .plyr__progress__buffer {
color: rgba(255, 255, 255, 0.25);
color: var(
--plyr-video-progress-buffered-background,
rgba(255, 255, 255, 0.25)
);
}
.plyr:-webkit-full-screen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:-ms-fullscreen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:fullscreen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:-webkit-full-screen video {
height: 100%;
}
.plyr:-ms-fullscreen video {
height: 100%;
}
.plyr:fullscreen video {
height: 100%;
}
.plyr:-webkit-full-screen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:-ms-fullscreen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:fullscreen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:fullscreen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:fullscreen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:fullscreen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:-webkit-full-screen.plyr--hide-controls {
cursor: none;
}
.plyr:-ms-fullscreen.plyr--hide-controls {
cursor: none;
}
.plyr:fullscreen.plyr--hide-controls {
cursor: none;
}
@media (min-width: 1024px) {
.plyr:-webkit-full-screen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
.plyr:-ms-fullscreen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
.plyr:fullscreen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
}
.plyr:-webkit-full-screen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:-webkit-full-screen video {
height: 100%;
}
.plyr:-webkit-full-screen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:-webkit-full-screen.plyr--hide-controls {
cursor: none;
}
@media (min-width: 1024px) {
.plyr:-webkit-full-screen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
}
.plyr:-moz-full-screen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:-moz-full-screen video {
height: 100%;
}
.plyr:-moz-full-screen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:-moz-full-screen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:-moz-full-screen.plyr--hide-controls {
cursor: none;
}
@media (min-width: 1024px) {
.plyr:-moz-full-screen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
}
.plyr:-ms-fullscreen {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
}
.plyr:-ms-fullscreen video {
height: 100%;
}
.plyr:-ms-fullscreen .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr:-ms-fullscreen.plyr--hide-controls {
cursor: none;
}
@media (min-width: 1024px) {
.plyr:-ms-fullscreen .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
}
.plyr--fullscreen-fallback {
background: #000;
border-radius: 0 !important;
height: 100%;
margin: 0;
width: 100%;
bottom: 0;
display: block;
left: 0;
position: fixed;
right: 0;
top: 0;
z-index: 10000000;
}
.plyr--fullscreen-fallback video {
height: 100%;
}
.plyr--fullscreen-fallback .plyr__video-wrapper {
height: 100%;
position: static;
}
.plyr--fullscreen-fallback.plyr--vimeo .plyr__video-wrapper {
height: 0;
position: relative;
}
.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen {
display: block;
}
.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen + svg {
display: none;
}
.plyr--fullscreen-fallback.plyr--hide-controls {
cursor: none;
}
@media (min-width: 1024px) {
.plyr--fullscreen-fallback .plyr__captions {
font-size: 21px;
font-size: var(--plyr-font-size-xlarge, 21px);
}
}
.plyr__ads {
border-radius: inherit;
bottom: 0;
cursor: pointer;
left: 0;
overflow: hidden;
position: absolute;
right: 0;
top: 0;
z-index: -1;
}
.plyr__ads > div,
.plyr__ads > div iframe {
height: 100%;
position: absolute;
width: 100%;
}
.plyr__ads::after {
background: #23282f;
border-radius: 2px;
bottom: 10px;
bottom: var(--plyr-control-spacing, 10px);
color: #fff;
content: attr(data-badge-text);
font-size: 11px;
padding: 2px 6px;
pointer-events: none;
position: absolute;
right: 10px;
right: var(--plyr-control-spacing, 10px);
z-index: 3;
}
.plyr__ads::after:empty {
display: none;
}
.plyr__cues {
background: currentColor;
display: block;
height: 5px;
height: var(--plyr-range-track-height, 5px);
left: 0;
margin: -var(--plyr-range-track-height, 5px) / 2 0 0;
opacity: 0.8;
position: absolute;
top: 50%;
width: 3px;
z-index: 3;
}
.plyr__preview-thumb {
background-color: rgba(255, 255, 255, 0.9);
background-color: var(--plyr-tooltip-background, rgba(255, 255, 255, 0.9));
border-radius: 3px;
bottom: 100%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
box-shadow: var(--plyr-tooltip-shadow, 0 1px 2px rgba(0, 0, 0, 0.15));
margin-bottom: calc(calc(10px / 2) * 2);
margin-bottom: calc(calc(var(--plyr-control-spacing, 10px) / 2) * 2);
opacity: 0;
padding: 3px;
padding: var(--plyr-tooltip-radius, 3px);
pointer-events: none;
position: absolute;
transform: translate(0, 10px) scale(0.8);
transform-origin: 50% 100%;
transition: transform 0.2s 0.1s ease, opacity 0.2s 0.1s ease;
z-index: 2;
}
.plyr__preview-thumb--is-shown {
opacity: 1;
transform: translate(0, 0) scale(1);
}
.plyr__preview-thumb::before {
border-left: 4px solid transparent;
border-left: var(--plyr-tooltip-arrow-size, 4px) solid transparent;
border-right: 4px solid transparent;
border-right: var(--plyr-tooltip-arrow-size, 4px) solid transparent;
border-top: 4px solid rgba(255, 255, 255, 0.9);
border-top: var(--plyr-tooltip-arrow-size, 4px) solid
var(--plyr-tooltip-background, rgba(255, 255, 255, 0.9));
bottom: calc(4px * -1);
bottom: calc(var(--plyr-tooltip-arrow-size, 4px) * -1);
content: '';
height: 0;
left: 50%;
position: absolute;
transform: translateX(-50%);
width: 0;
z-index: 2;
}
.plyr__preview-thumb__image-container {
background: #c1c8d1;
border-radius: calc(3px - 1px);
border-radius: calc(var(--plyr-tooltip-radius, 3px) - 1px);
overflow: hidden;
position: relative;
z-index: 0;
}
.plyr__preview-thumb__image-container img {
height: 100%;
left: 0;
max-height: none;
max-width: none;
position: absolute;
top: 0;
width: 100%;
}
.plyr__preview-thumb__time-container {
bottom: 6px;
left: 0;
position: absolute;
right: 0;
white-space: nowrap;
z-index: 3;
}
.plyr__preview-thumb__time-container span {
background-color: rgba(0, 0, 0, 0.55);
border-radius: calc(3px - 1px);
border-radius: calc(var(--plyr-tooltip-radius, 3px) - 1px);
color: #fff;
font-size: 13px;
font-size: var(--plyr-font-size-time, var(--plyr-font-size-small, 13px));
padding: 3px 6px;
}
.plyr__preview-scrubbing {
bottom: 0;
filter: blur(1px);
height: 100%;
left: 0;
margin: auto;
opacity: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
transition: opacity 0.3s ease;
width: 100%;
z-index: 1;
}
.plyr__preview-scrubbing--is-shown {
opacity: 1;
}
.plyr__preview-scrubbing img {
height: 100%;
left: 0;
max-height: none;
max-width: none;
object-fit: contain;
position: absolute;
top: 0;
width: 100%;
}
.plyr--no-transition {
transition: none !important;
}
.plyr__sr-only {
clip: rect(1px, 1px, 1px, 1px);
overflow: hidden;
border: 0 !important;
height: 1px !important;
padding: 0 !important;
position: absolute !important;
width: 1px !important;
}
.plyr [hidden] {
display: none !important;
}
================================================
FILE: src/assets/css/slider.css
================================================
/* rail style */
.vue-slider-rail {
background-color: rgba(128, 128, 128, 0.18);
border-radius: 15px;
}
/* process style */
.vue-slider-process {
background-color: #335eea;
border-radius: 15px;
}
/* dot style */
.vue-slider-dot-handle {
cursor: pointer;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #fff;
box-sizing: border-box;
box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.12);
visibility: hidden;
}
/* tooltip style */
.vue-slider-dot-tooltip-wrapper {
opacity: 0;
transition: all 1s;
}
.vue-slider-dot-tooltip-wrapper-show {
opacity: 1;
}
.vue-slider-dot-tooltip-inner {
font-size: 14px;
white-space: nowrap;
padding: 2px 6px;
min-width: 20px;
text-align: center;
color: #000;
border-radius: 5px;
border-color: #fff;
background-color: #fff;
box-sizing: content-box;
box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.08);
}
/* hover */
.vue-slider:hover .vue-slider-dot-handle,
.vue-slider:active .vue-slider-dot-handle {
visibility: visible;
}
/* volume style */
.volume-control .vue-slider-process {
opacity: 0.8;
background-color: var(--color-text);
border-radius: 15px;
}
.volume-control:hover .vue-slider-process {
background-color: #335eea;
}
/* nyancat */
.nyancat .vue-slider-rail {
background-color: rgba(128, 128, 128, 0.18);
padding: 2.5px 0px;
border-radius: 0;
}
.nyancat .vue-slider-process {
padding: 0px 1px;
top: -2px;
border-radius: 0;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0, #f00),
color-stop(17%, #f90),
color-stop(33%, #ff0),
color-stop(50%, #3f0),
color-stop(67%, #09f),
color-stop(83%, #63f)
);
}
.nyancat .vue-slider-dot-handle {
background: url('/img/logos/nyancat.gif');
background-size: 36px;
width: 36px;
height: 24px;
margin-top: -6px;
box-shadow: none;
border-radius: 0;
box-sizing: border-box;
visibility: visible;
}
.nyancat-stop .vue-slider-dot-handle {
background-image: url('/img/logos/nyancat-stop.png');
transition: 300ms;
}
/* lyrics */
.lyrics-page .vue-slider-rail {
background-color: rgba(128, 128, 128, 0.18);
border-radius: 2px;
height: 4px;
opacity: 0.88;
}
.lyrics-page .vue-slider-process {
background-color: #060606;
}
.lyrics-page .vue-slider-dot-handle {
background-color: #060606;
box-shadow: unset;
}
.lyrics-page .vue-slider-dot-tooltip {
display: none;
}
body[data-theme='dark'] .lyrics-page .vue-slider-process {
background-color: #fafafa;
}
body[data-theme='dark'] .lyrics-page .vue-slider-dot-handle {
background-color: #fff;
}
.lyrics-page[data-theme='dark'] .vue-slider-rail {
background-color: rgba(255, 255, 255, 0.18);
}
.lyrics-page[data-theme='dark'] .vue-slider-process,
.lyrics-page[data-theme='dark'] .vue-slider-dot-handle {
background-color: #fff;
}
================================================
FILE: src/assets/icons/index.js
================================================
import Vue from 'vue';
import SvgIcon from '@/components/SvgIcon';
Vue.component('svg-icon', SvgIcon);
const requireAll = requireContext => requireContext.keys().map(requireContext);
const req = require.context('./', true, /\.svg$/);
requireAll(req);
================================================
FILE: src/background.js
================================================
'use strict';
import {
app,
protocol,
BrowserWindow,
shell,
dialog,
globalShortcut,
nativeTheme,
screen,
} from 'electron';
import {
isWindows,
isMac,
isLinux,
isDevelopment,
isCreateTray,
isCreateMpris,
} from '@/utils/platform';
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib';
import { startNeteaseMusicApi } from './electron/services';
import { initIpcMain } from './electron/ipcMain.js';
import { createMenu } from './electron/menu';
import { createTray } from '@/electron/tray';
import { createTouchBar } from './electron/touchBar';
import { createDockMenu } from './electron/dockMenu';
import { registerGlobalShortcut } from './electron/globalShortcut';
import { autoUpdater } from 'electron-updater';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import { EventEmitter } from 'events';
import express from 'express';
import expressProxy from 'express-http-proxy';
import Store from 'electron-store';
import { createMpris, createDbus } from '@/electron/mpris';
import { spawn } from 'child_process';
const clc = require('cli-color');
const log = text => {
console.log(`${clc.blueBright('[background.js]')} ${text}`);
};
const closeOnLinux = (e, win, store) => {
let closeOpt = store.get('settings.closeAppOption');
if (closeOpt !== 'exit') {
e.preventDefault();
}
if (closeOpt === 'ask') {
dialog
.showMessageBox({
type: 'info',
title: 'Information',
cancelId: 2,
defaultId: 0,
message: '确定要关闭吗?',
buttons: ['最小化到托盘', '直接退出'],
checkboxLabel: '记住我的选择',
})
.then(result => {
if (result.checkboxChecked && result.response !== 2) {
win.webContents.send(
'rememberCloseAppOption',
result.response === 0 ? 'minimizeToTray' : 'exit'
);
}
if (result.response === 0) {
win.hide(); //调用 最小化实例方法
} else if (result.response === 1) {
win = null;
app.exit(); //exit()直接关闭客户端,不会执行quit();
}
})
.catch(err => {
log(err);
});
} else if (closeOpt === 'exit') {
win = null;
app.quit();
} else {
win.hide();
}
};
class Background {
constructor() {
this.window = null;
this.ypmTrayImpl = null;
this.store = new Store({
windowWidth: {
width: { type: 'number', default: 1440 },
height: { type: 'number', default: 840 },
},
});
this.neteaseMusicAPI = null;
this.expressApp = null;
this.willQuitApp = !isMac;
this.init();
}
init() {
log('initializing');
// Make sure the app is singleton.
if (!app.requestSingleInstanceLock()) return app.quit();
// start netease music api
this.neteaseMusicAPI = startNeteaseMusicApi();
// create Express app
this.createExpressApp();
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } },
]);
// handle app events
this.handleAppEvents();
// disable chromium mpris
if (isCreateMpris) {
app.commandLine.appendSwitch(
'disable-features',
'HardwareMediaKeyHandling,MediaSessionService'
);
}
}
async initDevtools() {
// Install Vue Devtools extension
try {
await installExtension(VUEJS_DEVTOOLS);
} catch (e) {
console.error('Vue Devtools failed to install:', e.toString());
}
// Exit cleanly on request from parent process in development mode.
if (isWindows) {
process.on('message', data => {
if (data === 'graceful-exit') {
app.quit();
}
});
} else {
process.on('SIGTERM', () => {
app.quit();
});
}
}
createExpressApp() {
log('creating express app');
const expressApp = express();
expressApp.use('/', express.static(__dirname + '/'));
expressApp.use('/api', expressProxy('http://127.0.0.1:10754'));
expressApp.use('/player', (req, res) => {
this.window.webContents
.executeJavaScript('window.yesplaymusic.player')
.then(result => {
res.send({
currentTrack: result._isPersonalFM
? result._personalFMTrack
: result._currentTrack,
progress: result._progress,
});
});
});
this.expressApp = expressApp.listen(27232, '127.0.0.1');
}
createWindow() {
log('creating app window');
const appearance = this.store.get('settings.appearance');
const showLibraryDefault = this.store.get('settings.showLibraryDefault');
const options = {
width: this.store.get('window.width') || 1440,
height: this.store.get('window.height') || 840,
minWidth: 1080,
minHeight: 720,
titleBarStyle: 'hiddenInset',
frame: !(
isWindows ||
(isLinux && this.store.get('settings.linuxEnableCustomTitlebar'))
),
title: 'YesPlayMusic',
show: false,
webPreferences: {
webSecurity: false,
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false,
},
backgroundColor:
((appearance === undefined || appearance === 'auto') &&
nativeTheme.shouldUseDarkColors) ||
appearance === 'dark'
? '#222'
: '#fff',
};
if (this.store.get('window.x') && this.store.get('window.y')) {
let x = this.store.get('window.x');
let y = this.store.get('window.y');
let displays = screen.getAllDisplays();
let isResetWindiw = false;
if (displays.length === 1) {
let { bounds } = displays[0];
if (
x < bounds.x ||
x > bounds.x + bounds.width - 50 ||
y < bounds.y ||
y > bounds.y + bounds.height - 50
) {
isResetWindiw = true;
}
} else {
isResetWindiw = true;
for (let i = 0; i < displays.length; i++) {
let { bounds } = displays[i];
if (
x > bounds.x &&
x < bounds.x + bounds.width &&
y > bounds.y &&
y < bounds.y - bounds.height
) {
// 检测到APP窗口当前处于一个可用的屏幕里,break
isResetWindiw = false;
break;
}
}
}
if (!isResetWindiw) {
options.x = x;
options.y = y;
}
}
this.window = new BrowserWindow(options);
// hide menu bar on Microsoft Windows and Linux
this.window.setMenuBarVisibility(false);
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
this.window.loadURL(
showLibraryDefault
? `${process.env.WEBPACK_DEV_SERVER_URL}/#/library`
: process.env.WEBPACK_DEV_SERVER_URL
);
if (!process.env.IS_TEST) this.window.webContents.openDevTools();
} else {
createProtocol('app');
this.window.loadURL(
showLibraryDefault
? 'http://localhost:27232/#/library'
: 'http://localhost:27232'
);
}
}
checkForUpdates() {
if (isDevelopment) return;
log('checkForUpdates');
autoUpdater.checkForUpdatesAndNotify();
const showNewVersionMessage = info => {
dialog
.showMessageBox({
title: '发现新版本 v' + info.version,
message: '发现新版本 v' + info.version,
detail: '是否前往 GitHub 下载新版本安装包?',
buttons: ['下载', '取消'],
type: 'question',
noLink: true,
})
.then(result => {
if (result.response === 0) {
shell.openExternal(
'https://github.com/qier222/YesPlayMusic/releases'
);
}
});
};
autoUpdater.on('update-available', info => {
showNewVersionMessage(info);
});
}
handleWindowEvents() {
this.window.once('ready-to-show', () => {
log('window ready-to-show event');
this.window.show();
this.store.set('window', this.window.getBounds());
});
this.window.on('close', e => {
log('window close event');
if (isLinux) {
closeOnLinux(e, this.window, this.store);
} else if (isMac) {
if (this.willQuitApp) {
this.window = null;
app.quit();
} else {
e.preventDefault();
this.window.hide();
}
} else {
let closeOpt = this.store.get('settings.closeAppOption');
if (this.willQuitApp && (closeOpt === 'exit' || closeOpt === 'ask')) {
this.window = null;
app.quit();
} else {
e.preventDefault();
this.window.hide();
}
}
});
this.window.on('resized', () => {
this.store.set('window', this.window.getBounds());
});
this.window.on('moved', () => {
this.store.set('window', this.window.getBounds());
});
this.window.on('maximize', () => {
this.window.webContents.send('isMaximized', true);
});
this.window.on('unmaximize', () => {
this.window.webContents.send('isMaximized', false);
});
this.window.webContents.on('new-window', function (e, url) {
e.preventDefault();
log('open url');
const excludeHosts = ['www.last.fm'];
const exclude = excludeHosts.find(host => url.includes(host));
if (exclude) {
const newWindow = new BrowserWindow({
width: 800,
height: 600,
titleBarStyle: 'default',
title: 'YesPlayMusic',
webPreferences: {
webSecurity: false,
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false,
},
});
newWindow.loadURL(url);
return;
}
shell.openExternal(url);
});
}
handleAppEvents() {
app.on('ready', async () => {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
log('app ready event');
// for development
if (isDevelopment) {
this.initDevtools();
}
// create window
this.createWindow();
this.window.once('ready-to-show', () => {
this.window.show();
});
this.handleWindowEvents();
// create tray
if (isCreateTray) {
this.trayEventEmitter = new EventEmitter();
this.ypmTrayImpl = createTray(
this.window,
this.trayEventEmitter,
this.store
);
}
// init ipcMain
initIpcMain(this.window, this.store, this.trayEventEmitter);
// set proxy
const proxyRules = this.store.get('proxy');
if (proxyRules) {
this.window.webContents.session.setProxy({ proxyRules }, result => {
log('finished setProxy', result);
});
}
// check for updates
this.checkForUpdates();
// create menu
createMenu(this.window, this.store);
// create dock menu for macOS
const createdDockMenu = createDockMenu(this.window);
if (createDockMenu && app.dock) app.dock.setMenu(createdDockMenu);
// create touch bar
const createdTouchBar = createTouchBar(this.window);
if (createdTouchBar) this.window.setTouchBar(createdTouchBar);
// register global shortcuts
if (this.store.get('settings.enableGlobalShortcut') !== false) {
registerGlobalShortcut(this.window, this.store);
}
// try to start osdlyrics process on start
if (this.store.get('settings.enableOsdlyricsSupport')) {
await createDbus(this.window);
log('try to start osdlyrics process');
const osdlyricsProcess = spawn('osdlyrics');
osdlyricsProcess.on('error', err => {
log(`failed to start osdlyrics: ${err.message}`);
});
osdlyricsProcess.on('exit', (code, signal) => {
log(`osdlyrics process exited with code ${code}, signal ${signal}`);
});
}
// create mpris
if (isCreateMpris) {
createMpris(this.window);
}
});
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
log('app activate event');
if (this.window === null) {
this.createWindow();
} else {
this.window.show();
}
});
app.on('window-all-closed', () => {
if (!isMac) {
app.quit();
}
});
app.on('before-quit', () => {
this.willQuitApp = true;
});
app.on('quit', () => {
this.expressApp.close();
});
app.on('will-quit', () => {
// unregister all global shortcuts
globalShortcut.unregisterAll();
});
if (!isMac) {
app.on('second-instance', (e, cl, wd) => {
if (this.window) {
this.window.show();
if (this.window.isMinimized()) {
this.window.restore();
}
this.window.focus();
}
});
}
}
}
new Background();
================================================
FILE: src/components/ArtistsInLine.vue
================================================
{{ computedPrefix }}
{{
ar.name
}}
{{ ar.name }}
,
================================================
FILE: src/components/ButtonIcon.vue
================================================
================================================
FILE: src/components/ButtonTwoTone.vue
================================================
================================================
FILE: src/components/ContextMenu.vue
================================================
================================================
FILE: src/components/Cover.vue
================================================
================================================
FILE: src/components/CoverRow.vue
================================================
{{
item.playCount | formatPlayCount
}}
{{ item.name }}
================================================
FILE: src/components/DailyTracksCard.vue
================================================
================================================
FILE: src/components/ExplicitSymbol.vue
================================================
================================================
FILE: src/components/FMCard.vue
================================================
================================================
FILE: src/components/LinuxTitlebar.vue
================================================
================================================
FILE: src/components/Modal.vue
================================================
================================================
FILE: src/components/ModalAddTrackToPlaylist.vue
================================================
新建歌单
{{ playlist.name }}
{{ playlist.trackCount }} 首
================================================
FILE: src/components/ModalNewPlaylist.vue
================================================
================================================
FILE: src/components/MvRow.vue
================================================
================================================
FILE: src/components/Navbar.vue
================================================
{{ $t('library.userProfileMenu.settings') }}
{{ $t('login.login') }}
{{ $t('library.userProfileMenu.logout') }}
{{ $t('nav.github') }}
================================================
FILE: src/components/Player.vue
================================================
{{ currentTrack.name }}
{{ ar.name }} ,
================================================
FILE: src/components/Scrollbar.vue
================================================
================================================
FILE: src/components/SvgIcon.vue
================================================
================================================
FILE: src/components/Toast.vue
================================================
{{ toast.text }}
================================================
FILE: src/components/TrackList.vue
================================================
{{ rightClickedTrackComputed.name }}
{{ rightClickedTrackComputed.ar[0].name }}
{{ $t('contextMenu.play') }}
{{
$t('contextMenu.addToQueue')
}}
从队列删除
{{ $t('contextMenu.saveToMyLikedSongs') }}
{{ $t('contextMenu.removeFromMyLikedSongs') }}
从歌单中删除
{{ $t('contextMenu.addToPlaylist') }}
{{
$t('contextMenu.copyUrl')
}}
从云盘中删除
================================================
FILE: src/components/TrackListItem.vue
================================================
{{ trackNo }}
{{ track.name }}
({{ subTitle }})
{{ track.dt | formatTime }}
{{ track.playCount }}
================================================
FILE: src/components/Win32Titlebar.vue
================================================
================================================
FILE: src/electron/dockMenu.js
================================================
const { Menu } = require('electron');
export function createDockMenu(win) {
return Menu.buildFromTemplate([
{
label: 'Play',
click() {
win.webContents.send('play');
},
},
{ type: 'separator' },
{
label: 'Next',
click() {
win.webContents.send('next');
},
},
{
label: 'Previous',
click() {
win.webContents.send('previous');
},
},
]);
}
================================================
FILE: src/electron/globalShortcut.js
================================================
import defaultShortcuts from '@/utils/shortcuts';
const { globalShortcut } = require('electron');
const clc = require('cli-color');
const log = text => {
console.log(`${clc.blueBright('[globalShortcut.js]')} ${text}`);
};
export function registerGlobalShortcut(win, store) {
log('registerGlobalShortcut');
let shortcuts = store.get('settings.shortcuts');
if (shortcuts === undefined) {
shortcuts = defaultShortcuts;
}
globalShortcut.register(
shortcuts.find(s => s.id === 'play').globalShortcut,
() => {
win.webContents.send('play');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'next').globalShortcut,
() => {
win.webContents.send('next');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'previous').globalShortcut,
() => {
win.webContents.send('previous');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'increaseVolume').globalShortcut,
() => {
win.webContents.send('increaseVolume');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'decreaseVolume').globalShortcut,
() => {
win.webContents.send('decreaseVolume');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'like').globalShortcut,
() => {
win.webContents.send('like');
}
);
globalShortcut.register(
shortcuts.find(s => s.id === 'minimize').globalShortcut,
() => {
win.isVisible() ? win.hide() : win.show();
}
);
}
================================================
FILE: src/electron/ipcMain.js
================================================
import { app, dialog, globalShortcut, ipcMain } from 'electron';
import UNM from '@unblockneteasemusic/rust-napi';
import { registerGlobalShortcut } from '@/electron/globalShortcut';
import cloneDeep from 'lodash/cloneDeep';
import shortcuts from '@/utils/shortcuts';
import { createMenu } from './menu';
import { isCreateTray, isMac } from '@/utils/platform';
const clc = require('cli-color');
const log = text => {
console.log(`${clc.blueBright('[ipcMain.js]')} ${text}`);
};
const exitAsk = (e, win) => {
e.preventDefault(); //阻止默认行为
dialog
.showMessageBox({
type: 'info',
title: 'Information',
cancelId: 2,
defaultId: 0,
message: '确定要关闭吗?',
buttons: ['最小化', '直接退出'],
})
.then(result => {
if (result.response == 0) {
e.preventDefault(); //阻止默认行为
win.minimize(); //调用 最小化实例方法
} else if (result.response == 1) {
win = null;
//app.quit();
app.exit(); //exit()直接关闭客户端,不会执行quit();
}
})
.catch(err => {
log(err);
});
};
const exitAskWithoutMac = (e, win) => {
e.preventDefault(); //阻止默认行为
dialog
.showMessageBox({
type: 'info',
title: 'Information',
cancelId: 2,
defaultId: 0,
message: '确定要关闭吗?',
buttons: ['最小化到托盘', '直接退出'],
checkboxLabel: '记住我的选择',
})
.then(result => {
if (result.checkboxChecked && result.response !== 2) {
win.webContents.send(
'rememberCloseAppOption',
result.response === 0 ? 'minimizeToTray' : 'exit'
);
}
if (result.response === 0) {
e.preventDefault(); //阻止默认行为
win.hide(); //调用 最小化实例方法
} else if (result.response === 1) {
win = null;
//app.quit();
app.exit(); //exit()直接关闭客户端,不会执行quit();
}
})
.catch(err => {
log(err);
});
};
const client = require('discord-rich-presence')('818936529484906596');
/**
* Make data a Buffer.
*
* @param {?} data The data to convert.
* @returns {import("buffer").Buffer} The converted data.
*/
function toBuffer(data) {
if (data instanceof Buffer) {
return data;
} else {
return Buffer.from(data);
}
}
/**
* Get the file base64 data from bilivideo.
*
* @param {string} url The URL to fetch.
* @returns {Promise} The file base64 data.
*/
async function getBiliVideoFile(url) {
const axios = await import('axios').then(m => m.default);
const response = await axios.get(url, {
headers: {
Referer: 'https://www.bilibili.com/',
'User-Agent': 'okhttp/3.4.1',
},
responseType: 'arraybuffer',
});
const buffer = toBuffer(response.data);
const encodedData = buffer.toString('base64');
return encodedData;
}
/**
* Parse the source string (`a, b`) to source list `['a', 'b']`.
*
* @param {import("@unblockneteasemusic/rust-napi").Executor} executor
* @param {string} sourceString The source string.
* @returns {string[]} The source list.
*/
function parseSourceStringToList(executor, sourceString) {
const availableSource = executor.list();
return sourceString
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => {
const isAvailable = availableSource.includes(s);
if (!isAvailable) {
log(`This source is not one of the supported source: ${s}`);
}
return isAvailable;
});
}
export function initIpcMain(win, store, trayEventEmitter) {
// WIP: Do not enable logging as it has some issues in non-blocking I/O environment.
// UNM.enableLogging(UNM.LoggingType.ConsoleEnv);
const unmExecutor = new UNM.Executor();
ipcMain.handle(
'unblock-music',
/**
*
* @param {*} _
* @param {string | null} sourceListString
* @param {Record} ncmTrack
* @param {UNM.Context} context
*/
async (_, sourceListString, ncmTrack, context) => {
// Formt the track input
// FIXME: Figure out the structure of Track
const song = {
id: ncmTrack.id && ncmTrack.id.toString(),
name: ncmTrack.name,
duration: ncmTrack.dt,
album: ncmTrack.al && {
id: ncmTrack.al.id && ncmTrack.al.id.toString(),
name: ncmTrack.al.name,
},
artists: ncmTrack.ar
? ncmTrack.ar.map(({ id, name }) => ({
id: id && id.toString(),
name,
}))
: [],
};
const sourceList =
typeof sourceListString === 'string'
? parseSourceStringToList(unmExecutor, sourceListString)
: ['ytdl', 'bilibili', 'pyncm', 'kugou'];
log(`[UNM] using source: ${sourceList.join(', ')}`);
log(`[UNM] using configuration: ${JSON.stringify(context)}`);
try {
// TODO: tell users to install yt-dlp.
const matchedAudio = await unmExecutor.search(
sourceList,
song,
context
);
const retrievedSong = await unmExecutor.retrieve(matchedAudio, context);
// bilibili's audio file needs some special treatment
if (retrievedSong.url.includes('bilivideo.com')) {
retrievedSong.url = await getBiliVideoFile(retrievedSong.url);
}
log(`respond with retrieve song…`);
log(JSON.stringify(matchedAudio));
return retrievedSong;
} catch (err) {
const errorMessage = err instanceof Error ? `${err.message}` : `${err}`;
log(`UnblockNeteaseMusic failed: ${errorMessage}`);
return null;
}
}
);
ipcMain.on('close', e => {
if (isMac) {
win.hide();
exitAsk(e, win);
} else {
let closeOpt = store.get('settings.closeAppOption');
if (closeOpt === 'exit') {
win = null;
//app.quit();
app.exit(); //exit()直接关闭客户端,不会执行quit();
} else if (closeOpt === 'minimizeToTray') {
e.preventDefault();
win.hide();
} else {
exitAskWithoutMac(e, win);
}
}
});
ipcMain.on('minimize', () => {
win.minimize();
});
ipcMain.on('maximizeOrUnmaximize', () => {
win.isMaximized() ? win.unmaximize() : win.maximize();
});
ipcMain.on('settings', (event, options) => {
store.set('settings', options);
if (options.enableGlobalShortcut) {
registerGlobalShortcut(win, store);
} else {
log('unregister global shortcut');
globalShortcut.unregisterAll();
}
});
ipcMain.on('playDiscordPresence', (event, track) => {
client.updatePresence({
details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','),
state: track.al.name,
endTimestamp: Date.now() + track.dt,
largeImageKey: track.al.picUrl,
largeImageText: 'Listening ' + track.name,
smallImageKey: 'play',
smallImageText: 'Playing',
instance: true,
});
});
ipcMain.on('pauseDiscordPresence', (event, track) => {
client.updatePresence({
details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','),
state: track.al.name,
largeImageKey: track.al.picUrl,
largeImageText: 'YesPlayMusic',
smallImageKey: 'pause',
smallImageText: 'Pause',
instance: true,
});
});
ipcMain.on('setProxy', (event, config) => {
const proxyRules = `${config.protocol}://${config.server}:${config.port}`;
store.set('proxy', proxyRules);
win.webContents.session.setProxy(
{
proxyRules,
},
() => {
log('finished setProxy');
}
);
});
ipcMain.on('removeProxy', (event, arg) => {
log('removeProxy');
win.webContents.session.setProxy({});
store.set('proxy', '');
});
ipcMain.on('switchGlobalShortcutStatusTemporary', (e, status) => {
log('switchGlobalShortcutStatusTemporary');
if (status === 'disable') {
globalShortcut.unregisterAll();
} else {
registerGlobalShortcut(win, store);
}
});
ipcMain.on('updateShortcut', (e, { id, type, shortcut }) => {
log('updateShortcut');
let shortcuts = store.get('settings.shortcuts');
let newShortcut = shortcuts.find(s => s.id === id);
newShortcut[type] = shortcut;
store.set('settings.shortcuts', shortcuts);
createMenu(win, store);
globalShortcut.unregisterAll();
registerGlobalShortcut(win, store);
});
ipcMain.on('restoreDefaultShortcuts', () => {
log('restoreDefaultShortcuts');
store.set('settings.shortcuts', cloneDeep(shortcuts));
createMenu(win, store);
globalShortcut.unregisterAll();
registerGlobalShortcut(win, store);
});
if (isCreateTray) {
ipcMain.on('updateTrayTooltip', (_, title) => {
trayEventEmitter.emit('updateTooltip', title);
});
ipcMain.on('updateTrayPlayState', (_, isPlaying) => {
trayEventEmitter.emit('updatePlayState', isPlaying);
});
ipcMain.on('updateTrayLikeState', (_, isLiked) => {
trayEventEmitter.emit('updateLikeState', isLiked);
});
ipcMain.on('updateTrayIcon', () => {
trayEventEmitter.emit('updateIcon');
});
}
}
================================================
FILE: src/electron/ipcRenderer.js
================================================
import store from '@/store';
const player = store.state.player;
export function ipcRenderer(vueInstance) {
const self = vueInstance;
// 添加专有的类名
document.body.setAttribute('data-electron', 'yes');
document.body.setAttribute(
'data-electron-os',
window.require('os').platform()
);
// ipc message channel
const electron = window.require('electron');
const ipcRenderer = electron.ipcRenderer;
// listens to the main process 'changeRouteTo' event and changes the route from
// inside this Vue instance, according to what path the main process requires.
// responds to Menu click() events at the main process and changes the route accordingly.
ipcRenderer.on('changeRouteTo', (event, path) => {
self.$router.push(path);
if (store.state.showLyrics) {
store.commit('toggleLyrics');
}
});
ipcRenderer.on('search', () => {
// 触发数据响应
self.$refs.navbar.$refs.searchInput.focus();
self.$refs.navbar.inputFocus = true;
});
ipcRenderer.on('play', () => {
player.playOrPause();
});
ipcRenderer.on('next', () => {
if (player.isPersonalFM) {
player.playNextFMTrack();
} else {
player.playNextTrack();
}
});
ipcRenderer.on('previous', () => {
player.playPrevTrack();
});
ipcRenderer.on('increaseVolume', () => {
if (player.volume + 0.1 >= 1) {
return (player.volume = 1);
}
player.volume += 0.1;
});
ipcRenderer.on('decreaseVolume', () => {
if (player.volume - 0.1 <= 0) {
return (player.volume = 0);
}
player.volume -= 0.1;
});
ipcRenderer.on('like', () => {
store.dispatch('likeATrack', player.currentTrack.id);
});
ipcRenderer.on('repeat', () => {
player.switchRepeatMode();
});
ipcRenderer.on('shuffle', () => {
player.switchShuffle();
});
ipcRenderer.on('routerGo', (event, where) => {
self.$refs.navbar.go(where);
});
ipcRenderer.on('nextUp', () => {
self.$refs.player.goToNextTracksPage();
});
ipcRenderer.on('rememberCloseAppOption', (event, value) => {
store.commit('updateSettings', {
key: 'closeAppOption',
value,
});
});
ipcRenderer.on('setPosition', (event, position) => {
player._howler.seek(position);
});
}
================================================
FILE: src/electron/menu.js
================================================
import defaultShortcuts from '@/utils/shortcuts';
const { app, Menu } = require('electron');
// import { autoUpdater } from "electron-updater"
// const version = app.getVersion();
const isMac = process.platform === 'darwin';
export function createMenu(win, store) {
let shortcuts = store.get('settings.shortcuts');
if (shortcuts === undefined) {
shortcuts = defaultShortcuts;
}
let menu = null;
const template = [
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ type: 'separator' },
{
label: 'Preferences...',
accelerator: 'CmdOrCtrl+,',
click: () => {
win.webContents.send('changeRouteTo', '/settings');
},
role: 'preferences',
},
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideothers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []),
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startspeaking' }, { role: 'stopspeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
{
label: 'Search',
accelerator: 'CmdOrCtrl+F',
click: () => {
win.webContents.send('search');
},
},
],
},
{
label: 'Controls',
submenu: [
{
label: 'Play',
accelerator: shortcuts.find(s => s.id === 'play').shortcut,
click: () => {
win.webContents.send('play');
},
},
{
label: 'Next',
accelerator: shortcuts.find(s => s.id === 'next').shortcut,
click: () => {
win.webContents.send('next');
},
},
{
label: 'Previous',
accelerator: shortcuts.find(s => s.id === 'previous').shortcut,
click: () => {
win.webContents.send('previous');
},
},
{
label: 'Increase Volume',
accelerator: shortcuts.find(s => s.id === 'increaseVolume').shortcut,
click: () => {
win.webContents.send('increaseVolume');
},
},
{
label: 'Decrease Volume',
accelerator: shortcuts.find(s => s.id === 'decreaseVolume').shortcut,
click: () => {
win.webContents.send('decreaseVolume');
},
},
{
label: 'Like',
accelerator: shortcuts.find(s => s.id === 'like').shortcut,
click: () => {
win.webContents.send('like');
},
},
{
label: 'Repeat',
accelerator: 'Alt+R',
click: () => {
win.webContents.send('repeat');
},
},
{
label: 'Shuffle',
accelerator: 'Alt+S',
click: () => {
win.webContents.send('shuffle');
},
},
],
},
{
label: 'Window',
submenu: [
{ role: 'close' },
{ role: 'minimize' },
{ role: 'zoom' },
{ role: 'reload' },
{ role: 'forcereload' },
{ role: 'toggledevtools' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{
role: 'window',
id: 'window',
label: 'YesPlayMusic',
type: 'checkbox',
checked: true,
click: () => {
const current = menu.getMenuItemById('window');
if (current.checked === false) {
win.hide();
} else {
win.show();
}
},
},
]
: [{ role: 'close' }]),
],
},
{
label: 'Help',
submenu: [
{
label: 'GitHub',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://github.com/qier222/YesPlayMusic');
},
},
{
label: 'Electron',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://electronjs.org');
},
},
{
label: '开发者工具',
accelerator: 'F12',
click: () => {
win.webContents.openDevTools();
},
},
],
},
];
// for window
// if (process.platform === "win32") {
// template.push({
// label: "Help",
// submenu: [
// {
// label: `Current version v${version}`,
// enabled: false,
// },
// {
// label: "Check for update",
// accelerator: "Ctrl+U",
// click: (item, focusedWindow) => {
// win = focusedWindow;
// updateSource = "menu";
// autoUpdater.checkForUpdates();
// },
// },
// ],
// });
// }
menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
================================================
FILE: src/electron/mpris.js
================================================
import dbus from 'dbus-next';
import { ipcMain, app } from 'electron';
export function createMpris(window) {
const Player = require('mpris-service');
const renderer = window.webContents;
const player = Player({
name: 'yesplaymusic',
identity: 'YesPlayMusic',
});
player.on('next', () => renderer.send('next'));
player.on('previous', () => renderer.send('previous'));
player.on('playpause', () => renderer.send('play'));
player.on('play', () => renderer.send('play'));
player.on('pause', () => renderer.send('play'));
player.on('quit', () => app.exit());
player.on('position', args =>
renderer.send('setPosition', args.position / 1000 / 1000)
);
player.on('loopStatus', () => renderer.send('repeat'));
player.on('shuffle', () => renderer.send('shuffle'));
ipcMain.on('player', (e, { playing }) => {
player.playbackStatus = playing
? Player.PLAYBACK_STATUS_PLAYING
: Player.PLAYBACK_STATUS_PAUSED;
});
ipcMain.on('metadata', (e, metadata) => {
// 更新 Mpris 状态前将位置设为0, 否则 OSDLyrics 获取到的进度是上首音乐切换时的进度
player.getPosition = () => 0;
player.metadata = {
'mpris:trackid': player.objectPath('track/' + metadata.trackId),
'mpris:artUrl': metadata.artwork[0].src,
'mpris:length': metadata.length * 1000 * 1000,
'xesam:title': metadata.title,
'xesam:album': metadata.album,
'xesam:artist': metadata.artist.split(','),
'xesam:url': metadata.url,
};
});
ipcMain.on('playerCurrentTrackTime', (e, position) => {
player.getPosition = () => position * 1000 * 1000;
player.seeked(position * 1000 * 1000);
});
ipcMain.on('seeked', (e, position) => {
player.seeked(position * 1000 * 1000);
});
ipcMain.on('switchRepeatMode', (e, mode) => {
switch (mode) {
case 'off':
player.loopStatus = Player.LOOP_STATUS_NONE;
break;
case 'one':
player.loopStatus = Player.LOOP_STATUS_TRACK;
break;
case 'on':
player.loopStatus = Player.LOOP_STATUS_PLAYLIST;
break;
}
});
ipcMain.on('switchShuffle', (e, shuffle) => {
player.shuffle = shuffle;
});
}
export async function createDbus(window) {
const bus = dbus.sessionBus();
const Variant = dbus.Variant;
const osdService = await bus.getProxyObject(
'org.osdlyrics.Daemon',
'/org/osdlyrics/Lyrics'
);
const osdInterface = osdService.getInterface('org.osdlyrics.Lyrics');
ipcMain.on('sendLyrics', async (e, { track, lyrics }) => {
const metadata = {
title: new Variant('s', track.name),
artist: new Variant('s', track.ar.map(ar => ar.name).join(', ')),
};
await osdInterface.SetLyricContent(metadata, Buffer.from(lyrics));
window.webContents.send('saveLyricFinished');
});
}
================================================
FILE: src/electron/services.js
================================================
import clc from 'cli-color';
import checkAuthToken from '../utils/checkAuthToken';
import server from '@neteaseapireborn/api/server';
export async function startNeteaseMusicApi() {
// Let user know that the service is starting
console.log(`${clc.redBright('[NetEase API]')} initiating NCM API`);
// Load the NCM API.
await server.serveNcmApi({
port: 10754,
moduleDefs: require('../ncmModDef'),
});
}
================================================
FILE: src/electron/touchBar.js
================================================
const { TouchBar, nativeImage, ipcMain } = require('electron');
const { TouchBarButton, TouchBarSpacer } = TouchBar;
const path = require('path');
export function createTouchBar(window) {
const renderer = window.webContents;
// Icon follow touchbar design guideline.
// See: https://developer.apple.com/design/human-interface-guidelines/macos/touch-bar/touch-bar-icons-and-images/
// Icon Resource: https://devimages-cdn.apple.com/design/resources/
function getNativeIcon(name) {
return nativeImage.createFromPath(
// eslint-disable-next-line no-undef
path.join(__static, 'img/touchbar/', name)
);
}
const previousPage = new TouchBarButton({
click: () => {
renderer.send('routerGo', 'back');
},
icon: getNativeIcon('page_prev.png'),
});
const nextPage = new TouchBarButton({
click: () => {
renderer.send('routerGo', 'forward');
},
icon: getNativeIcon('page_next.png'),
});
const searchButton = new TouchBarButton({
click: () => {
renderer.send('search');
},
icon: getNativeIcon('search.png'),
});
const playButton = new TouchBarButton({
click: () => {
renderer.send('play');
},
icon: getNativeIcon('play.png'),
});
const previousTrackButton = new TouchBarButton({
click: () => {
renderer.send('previous');
},
icon: getNativeIcon('backward.png'),
});
const nextTrackButton = new TouchBarButton({
click: () => {
renderer.send('next');
},
icon: getNativeIcon('forward.png'),
});
const likeButton = new TouchBarButton({
click: () => {
renderer.send('like');
},
icon: getNativeIcon('like.png'),
});
const nextUpButton = new TouchBarButton({
click: () => {
renderer.send('nextUp');
},
icon: getNativeIcon('next_up.png'),
});
ipcMain.on('player', (e, { playing, likedCurrentTrack }) => {
playButton.icon =
playing === true ? getNativeIcon('pause.png') : getNativeIcon('play.png');
likeButton.icon = likedCurrentTrack
? getNativeIcon('like_fill.png')
: getNativeIcon('like.png');
});
const touchBar = new TouchBar({
items: [
previousPage,
nextPage,
searchButton,
new TouchBarSpacer({ size: 'flexible' }),
previousTrackButton,
playButton,
nextTrackButton,
new TouchBarSpacer({ size: 'flexible' }),
likeButton,
nextUpButton,
],
});
return touchBar;
}
================================================
FILE: src/electron/tray.js
================================================
/* global __static */
import path from 'path';
import { app, nativeImage, Tray, Menu, nativeTheme } from 'electron';
import { isLinux } from '@/utils/platform';
function createMenuTemplate(win) {
return [
{
label: '播放',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/play.png')
),
click: () => {
win.webContents.send('play');
},
id: 'play',
},
{
label: '暂停',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/pause.png')
),
click: () => {
win.webContents.send('play');
},
id: 'pause',
visible: false,
},
{
label: '上一首',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/left.png')
),
accelerator: 'CmdOrCtrl+Left',
click: () => {
win.webContents.send('previous');
},
},
{
label: '下一首',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/right.png')
),
accelerator: 'CmdOrCtrl+Right',
click: () => {
win.webContents.send('next');
},
},
{
label: '循环播放',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/repeat.png')
),
accelerator: 'Alt+R',
click: () => {
win.webContents.send('repeat');
},
},
{
label: '加入喜欢',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/like.png')
),
accelerator: 'CmdOrCtrl+L',
click: () => {
win.webContents.send('like');
},
id: 'like',
},
{
label: '取消喜欢',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/unlike.png')
),
accelerator: 'CmdOrCtrl+L',
click: () => {
win.webContents.send('like');
},
id: 'unlike',
visible: false,
},
{
label: '退出',
icon: nativeImage.createFromPath(
path.join(__static, 'img/icons/exit.png')
),
accelerator: 'CmdOrCtrl+W',
click: () => {
app.exit();
},
},
];
}
// linux下托盘的实现方式比较迷惑
// right-click无法在linux下使用
// click在默认行为下会弹出一个contextMenu,里面的唯一选项才会调用click事件
// setContextMenu应该是目前唯一能在linux下使用托盘菜单api
// 但是无法区分鼠标左右键
// 发现openSUSE KDE环境可以区分鼠标左右键
// 添加左键支持
// 2022.05.17
class YPMTrayLinuxImpl {
constructor(tray, win, emitter, store) {
this.tray = tray;
this.win = win;
this.emitter = emitter;
this.store = store;
this.template = undefined;
this.initTemplate();
this.contextMenu = Menu.buildFromTemplate(this.template);
this.tray.setContextMenu(this.contextMenu);
this.handleEvents();
}
initTemplate() {
//在linux下,鼠标左右键都会呼出contextMenu
//所以此处单独为linux添加一个 显示主面板 选项
this.template = [
{
label: '显示主面板',
click: () => {
this.win.show();
},
},
{
type: 'separator',
},
].concat(createMenuTemplate(this.win));
}
handleEvents() {
this.tray.on('click', () => {
this.win.show();
});
this.emitter.on('updateTooltip', title => this.tray.setToolTip(title));
this.emitter.on('updatePlayState', isPlaying => {
this.contextMenu.getMenuItemById('play').visible = !isPlaying;
this.contextMenu.getMenuItemById('pause').visible = isPlaying;
this.tray.setContextMenu(this.contextMenu);
});
this.emitter.on('updateLikeState', isLiked => {
this.contextMenu.getMenuItemById('like').visible = !isLiked;
this.contextMenu.getMenuItemById('unlike').visible = isLiked;
this.tray.setContextMenu(this.contextMenu);
});
this.emitter.on('updateIcon', () => {
this.updateIcon();
});
}
updateIcon() {
let trayIconSetting = this.store.get('settings.trayIconTheme') || 'auto';
let iconTheme;
if (trayIconSetting === 'auto') {
iconTheme = nativeTheme.shouldUseDarkColors ? 'light' : 'dark';
} else {
iconTheme = trayIconSetting;
}
let icon = nativeImage
.createFromPath(path.join(__static, `img/icons/menu-${iconTheme}@88.png`))
.resize({
height: 20,
width: 20,
});
this.tray.setImage(icon);
}
}
class YPMTrayWindowsImpl {
constructor(tray, win, emitter, store) {
this.tray = tray;
this.win = win;
this.emitter = emitter;
this.store = store;
this.template = createMenuTemplate(win);
this.contextMenu = Menu.buildFromTemplate(this.template);
this.isPlaying = false;
this.curDisplayPlaying = false;
this.isLiked = false;
this.curDisplayLiked = false;
this.handleEvents();
}
handleEvents() {
this.tray.on('click', () => {
this.win.show();
});
this.tray.on('right-click', () => {
if (this.isPlaying !== this.curDisplayPlaying) {
this.curDisplayPlaying = this.isPlaying;
this.contextMenu.getMenuItemById('play').visible = !this.isPlaying;
this.contextMenu.getMenuItemById('pause').visible = this.isPlaying;
}
if (this.isLiked !== this.curDisplayLiked) {
this.curDisplayLiked = this.isLiked;
this.contextMenu.getMenuItemById('like').visible = !this.isLiked;
this.contextMenu.getMenuItemById('unlike').visible = this.isLiked;
}
this.tray.popUpContextMenu(this.contextMenu);
});
this.emitter.on('updateTooltip', title => this.tray.setToolTip(title));
this.emitter.on(
'updatePlayState',
isPlaying => (this.isPlaying = isPlaying)
);
this.emitter.on('updateLikeState', isLiked => (this.isLiked = isLiked));
this.emitter.on('updateIcon', () => {
this.updateIcon();
});
}
updateIcon() {
let trayIconSetting = this.store.get('settings.trayIconTheme') || 'auto';
let iconTheme;
if (trayIconSetting === 'auto') {
iconTheme = nativeTheme.shouldUseDarkColors ? 'light' : 'dark';
} else {
iconTheme = trayIconSetting;
}
let icon = nativeImage
.createFromPath(path.join(__static, `img/icons/menu-${iconTheme}@88.png`))
.resize({
height: 20,
width: 20,
});
this.tray.setImage(icon);
}
}
export function createTray(win, eventEmitter, store) {
let trayIconSetting = store.get('settings.trayIconTheme') || 'auto';
let iconTheme;
if (trayIconSetting === 'auto') {
iconTheme = nativeTheme.shouldUseDarkColors ? 'light' : 'dark';
} else {
iconTheme = trayIconSetting;
}
let icon = nativeImage
.createFromPath(path.join(__static, `img/icons/menu-${iconTheme}@88.png`))
.resize({
height: 20,
width: 20,
});
let tray = new Tray(icon);
tray.setToolTip('YesPlayMusic');
return isLinux
? new YPMTrayLinuxImpl(tray, win, eventEmitter, store)
: new YPMTrayWindowsImpl(tray, win, eventEmitter, store);
}
================================================
FILE: src/locale/index.js
================================================
import Vue from 'vue';
import VueClipboard from 'vue-clipboard2';
import VueI18n from 'vue-i18n';
import store from '@/store';
import en from './lang/en.js';
import zhCN from './lang/zh-CN.js';
import zhTW from './lang/zh-TW.js';
import tr from './lang/tr.js';
Vue.use(VueClipboard);
Vue.use(VueI18n);
const i18n = new VueI18n({
locale: store.state.settings.lang,
messages: {
en,
'zh-CN': zhCN,
'zh-TW': zhTW,
tr,
},
silentTranslationWarn: true,
});
export default i18n;
================================================
FILE: src/locale/lang/en.js
================================================
export default {
common: {
play: 'PLAY',
songs: 'Songs',
},
nav: {
home: 'Home',
explore: 'Explore',
library: 'Library',
search: 'Search',
github: 'GitHub Repo',
},
footer: {
settings: 'Settings',
},
home: {
recommendPlaylist: 'Recommended Playlists',
recommendArtist: 'Recommended Artists',
newAlbum: 'Latest Albums',
seeMore: 'SEE MORE',
charts: 'Charts',
},
library: {
sLibrary: "'s Library",
likedSongs: 'Liked Songs',
sLikedSongs: "'s Liked Songs",
playlists: 'Playlists',
albums: 'Albums',
artists: 'Artists',
mvs: 'MVs',
cloudDisk: 'Cloud Disk',
newPlayList: 'New Playlist',
uploadSongs: 'Upload Songs',
playHistory: {
title: 'Play History',
week: 'Latest Week',
all: 'All Time',
},
userProfileMenu: {
settings: 'Settings',
logout: 'Logout',
},
},
explore: {
explore: 'Explore',
loadMore: 'Load More',
},
artist: {
latestRelease: 'Latest Releases',
latestMV: 'Latest MV',
popularSongs: 'Popular Songs',
showMore: 'SHOW MORE',
showLess: 'SHOW LESS',
EPsSingles: 'EPs & Singles',
albums: 'Albums',
withAlbums: 'Albums',
artist: 'Artist',
videos: 'Music Videos',
following: 'Following',
follow: 'Follow',
similarArtists: 'Similar Artists',
artistDesc: 'Artist Description',
},
album: {
released: 'Released',
albumDesc: 'Album Description',
},
playlist: {
playlist: 'Playlists',
updatedAt: 'Updated at',
search: 'Search in playlist',
},
login: {
accessToAll: 'Access to all data',
loginText: 'Login to Netease',
search: 'Search account',
readonly: 'Only access to public data',
usernameLogin: 'Username Login',
searchHolder: 'Your account username',
enterTip: "Press 'enter' to search",
choose: 'Choose your account',
confirm: 'Confirm',
countryCode: 'Country code',
phone: 'Phone',
email: 'Email address',
password: 'Password',
login: 'Login',
loginWithEmail: 'Login with Email',
loginWithPhone: 'Login with Phone',
notice: `YesPlayMusic promises not to save any of your account information to the cloud.
Your password will be MD5 encrypted locally and then transmitted to NetEase Music API.
YesPlayMusic is not the official website of NetEase Music, please consider carefully before entering account information. You can also go to YesPlayMusic's GitHub repository to build and use the self-hosted NetEase Music API.`,
noticeElectron: `Your password will be MD5 encrypted locally and then transmitted to NetEase Music API.
YesPlayMusic promises not to save any of your account information to the cloud.
`,
},
mv: {
moreVideo: 'More Videos',
},
next: {
nowPlaying: 'Now Playing',
nextUp: 'Next Up',
},
player: {
like: 'Like',
unlike: 'Unlike',
previous: 'Previous Song',
next: 'Next Song',
repeat: 'Repeat',
repeatTrack: 'Repeat Track',
shuffle: 'Shuffle',
reversed: 'Reversed',
play: 'Play',
pause: 'Pause',
mute: 'Mute',
nextUp: 'Next Up',
translationLyric: 'lyric (trans)',
PronunciationLyric: 'lyric (pronounce)',
},
modal: {
close: 'Close',
},
search: {
artist: 'Artists',
album: 'Albums',
song: 'Songs',
mv: 'Music Videos',
playlist: 'Playlists',
noResult: 'No Results',
searchFor: 'Search for',
},
settings: {
settings: 'Settings',
logout: 'LOGOUT',
language: 'Languages',
lyric: 'Lyric',
others: 'Others',
customization: 'Customization',
MusicGenrePreference: {
text: 'Music Language Preference',
none: 'No preferences',
mandarin: 'Mandarin',
western: 'Europe & America',
korean: 'Korean',
japanese: 'Japanese',
},
musicQuality: {
text: 'Music Quality',
low: 'Low',
medium: 'Medium',
high: 'High',
lossless: 'Lossless',
},
cacheLimit: {
text: 'Songs Cache limit',
none: 'None',
},
lyricFontSize: {
text: 'Lyric Font Size',
small: 'Small',
medium: 'Medium',
large: 'Large (Default)',
xlarge: 'X-Large',
},
deviceSelector: 'Audio Output Device',
permissionRequired: 'Microphone Permission Required',
appearance: {
text: 'Appearance',
auto: 'Auto',
light: 'Light',
dark: 'Dark',
},
trayIcon: {
text: 'Tray Icon Color',
auto: 'Auto',
light: 'Light',
dark: 'Dark',
},
automaticallyCacheSongs: 'Automatically cache songs',
clearSongsCache: 'Clear Songs Cache',
cacheCount: 'Cached {song} songs ({size})',
showLyricsTranslation: 'Show lyrics translation',
showPlaylistsByAppleMusic: 'Show playlists by Apple Music',
enableDiscordRichPresence: 'Enable Discord Rich Presence',
enableGlobalShortcut: 'Enable Global Shortcut',
showLibraryDefault: 'Show Library after App Launched',
subTitleDefault: 'Show Alias for Subtitle by default',
enableReversedMode: 'Enable Reversed Mode (Experimental)',
enableCustomTitlebar: 'Enable custom title bar (Need restart)',
showLyricsTime: 'Display current time',
lyricsBackground: {
text: 'Show Lyrics Background',
off: 'Off',
on: 'On',
dynamic: 'Dynamic (High GPU usage)',
},
closeAppOption: {
text: 'Close App...',
ask: 'Ask',
exit: 'Exit',
minimizeToTray: 'Minimize to tray',
},
enableOsdlyricsSupport: {
title: 'desktop lyrics support',
desc1:
'Only takes effect under Linux. After enabled, it downloads the lyrics file to the local, and tries to launch OSDLyrics at startup.',
desc2:
'Please ensure that you have installed OSDLyrics before turning on this.',
},
unm: {
enable: 'Enable',
audioSource: {
title: 'Audio Sources',
},
enableFlac: {
title: 'Enable FLAC Sources',
desc: 'To take effect, it may be required to clear the cache after enabling this function.',
},
searchMode: {
title: 'Audio Search Mode',
fast: 'Speed Priority',
order: 'Order Priority',
},
cookie: {
joox: 'Cookie for Joox use',
qq: 'Cookie for QQ use',
desc1: 'Click here for the configuration instruction. ',
desc2: 'Leave empty to pick up the default value',
},
ytdl: 'The youtube-dl Executable File for YtDl',
proxy: {
title: 'Proxy Server for UNM',
desc1:
'The proxy server to use for requesting services such as YouTube',
desc2: 'Leave empty to pick up the default value',
},
},
},
contextMenu: {
play: 'Play',
addToQueue: 'Add to queue',
saveToMyLikedSongs: 'Save to my Liked Songs',
removeFromMyLikedSongs: 'Remove from my Liked Songs',
saveToLibrary: 'Save to library',
removeFromLibrary: 'Remove from library',
addToPlaylist: 'Add to playlist',
searchInPlaylist: 'Search in playlist',
copyUrl: 'Copy URL',
openInBrowser: 'Open in Browser',
allPlaylists: 'All Playlists',
minePlaylists: 'My Playlists',
likedPlaylists: 'Liked Playlists',
cardiacMode: 'Cardiac Mode',
copyLyric: 'Copy Lyric',
copyLyricWithTranslation: 'Copy Lyric With Translation',
},
toast: {
savedToPlaylist: 'Saved to playlist',
removedFromPlaylist: 'Removed from playlist',
savedToMyLikedSongs: 'Saved to my Liked Songs',
removedFromMyLikedSongs: 'Removed from my Liked Songs',
copied: 'Copied',
copyFailed: 'Copy failed: ',
needToLogin: 'Need to log into netease account',
},
};
================================================
FILE: src/locale/lang/tr.js
================================================
export default {
common: {
play: 'OYNAT',
songs: 'Müzikler',
},
nav: {
home: 'Anasayfa',
explore: 'Keşfet',
library: 'Kitaplık',
search: 'Ara',
github: 'GitHub Repo',
},
footer: {
settings: 'Ayarlar',
},
home: {
recommendPlaylist: 'Önerilen Çalma Listeier',
recommendArtist: 'Önerilen Sanatçılar',
newAlbum: 'Son Çıkan Albümler',
seeMore: 'DAHA FAZLASI',
charts: 'Listeler',
},
library: {
sLibrary: "'in Kütüphanesi",
likedSongs: 'Beğenilen Müzikler',
sLikedSongs: "'in Beğendiği Müzikler",
playlists: 'Çalma Listeleri',
albums: 'Albümler',
artists: 'Sanatçılar',
mvs: 'MVs',
cloudDisk: 'Cloud Disk',
newPlayList: 'Yeni Çalma Listesi',
uploadSongs: 'Upload Songs',
playHistory: {
title: 'Play History',
week: 'Latest Week',
all: 'All Time',
},
userProfileMenu: {
settings: 'Ayarlar',
logout: 'Çıkış Yap',
},
},
explore: {
explore: 'Keşfet',
loadMore: 'Daha Fazlası',
},
artist: {
latestRelease: 'Son Çıkanlar',
popularSongs: 'Popüler Müzikler',
showMore: 'Daha Fazlası',
showLess: 'Daha Azı',
EPsSingles: 'EPs & Singles',
albums: 'Albümler',
withAlbums: 'Albümler',
artist: 'Sanatçı',
videos: 'Müzik Videoları',
following: 'Takip Ediyor',
follow: 'Takip Et',
},
album: {
released: 'Yayınlandı',
},
playlist: {
playlist: 'Çalma Listeleri',
updatedAt: 'Tarihinde Güncellendş',
search: 'Çalma Listesinde Ara',
},
login: {
accessToAll: 'Tüm verilere eriş',
loginText: "Netease'e giriş yap",
search: 'Hesap ara',
readonly: 'Sadece halka açık verilere erişir',
usernameLogin: 'Kullanıcı adı giriş',
searchHolder: 'Hesabının kullanıcı adı',
enterTip: "Aramak için 'enter'e basınız",
choose: 'Hesabını seç',
confirm: 'Onayla',
countryCode: 'Ülke kodu',
phone: 'Telefon',
email: 'Email adresi',
password: 'Şifre',
login: 'Giriş Yap',
loginWithEmail: 'Email ile giriş yap',
loginWithPhone: 'Phone ile giriş yap',
notice: `YesPlayMusic hesabınızın hiçbir bilgisini kaydetmeyeceğine dair söz veriyor
Şifren MD5 şifreleme ile yerel olarak şifrelenir ve daha sonra NetEase Müzik API'sine gönderilir
YesPlayMusic, NetEase Music'in resmi websitesi değildir, lütfen hesap bilgilerinizi girmeden önce dikkatlice düşününüz. Aynı zamanda, Kendi NetEase Musix API'nızı host etmek için YesPlayMusic'in GitHub Repo'suna gidebilirsiniz.`,
noticeElectron: `YesPlayMusic hesabınızın hiçbir bilgisini kaydetmeyeceğine dair söz veriyor
Şifren MD5 şifreleme ile yerel olarak şifrelenir ve daha sonra NetEase Müzik API'sine gönderilir
`,
},
mv: {
moreVideo: 'Daha Fazla Video',
},
next: {
nowPlaying: 'Şuan çalıyor',
nextUp: 'Sıradaki',
},
player: {
like: 'Beğen',
unlike: 'Aksine',
previous: 'Önceki Müzik',
next: 'Sonraki Müzik',
repeat: 'Tekrarla',
repeatTrack: 'Parçayı Tekrarla',
shuffle: 'Karıştır',
play: 'Oynat',
pause: 'Durdur',
mute: 'Sesi kapat',
nextUp: 'Sıradaki',
translationLyric: 'şarkı sözleri (çeviri)',
PronunciationLyric: 'şarkı sözleri (çeviri)',
},
modal: {
close: 'Kapat',
},
search: {
artist: 'Sanatçılar',
album: 'Albümler',
song: 'Müzikler',
mv: 'Müzik Videoları',
playlist: 'Çalma Listeleri',
noResult: 'Sonuç Bulunamadı',
searchFor: 'Search for',
},
settings: {
settings: 'Ayarlar',
logout: 'ÇIKIŞ YAP',
language: 'Diller',
lyric: 'Şarkı Sözleri',
others: 'Diğerleri',
customization: 'Özelleştirme',
MusicGenrePreference: {
text: 'Müzik Dili Tercihi',
none: 'Tercih yok',
mandarin: 'Çince dili',
western: 'Avrupa ve Amerika',
korean: 'Korece',
japanese: 'Japonca',
},
musicQuality: {
text: 'Müzik Kalitesi',
low: 'Düşük',
medium: 'Orta',
high: 'Yüksek',
lossless: 'Kaliteli',
},
cacheLimit: {
text: 'Şarkılar Önbellek sınırı',
none: 'Yok',
},
lyricFontSize: {
text: 'Şarkı Sözleri Yazı Boyutu',
small: 'Küçük',
medium: 'Orta',
large: 'Büyük(Varsayılan)',
xlarge: 'Çok-Büyük',
},
deviceSelector: 'Ses Çıkış Cihazı',
permissionRequired: 'Mikrofon izni gerekiyor',
appearance: {
text: 'Görünüş',
auto: 'Otomatik',
light: 'Aydınlık',
dark: 'Karanlık',
},
trayIcon: {
text: 'Tepsi Simgesi Rengi',
auto: 'Otomatik',
light: 'Aydınlık',
dark: 'Karanlık',
},
automaticallyCacheSongs: 'Müzikleri otomatik çerezle',
clearSongsCache: 'Müzik çerezlerini temizle',
cacheCount: 'Çerezlenen {song} Müzikler ({size})',
showLyricsTranslation: 'Müzik sözlerinin çevirilerini göster',
showPlaylistsByAppleMusic: "Apple Music'in Çalma Listelerini Göster",
enableDiscordRichPresence: 'Discord gösterimini aktifleştir',
showLibraryDefault: 'Kitaplık Varsayılanını göster',
subTitleDefault: 'Show Alias for Subtitle by default',
enableReversedMode: 'Enable Reversed Mode (Experimental)',
enableCustomTitlebar: 'Enable custom title bar (Need restart)',
lyricsBackground: {
text: 'Şarkı Sözleri Arka Planını Göster',
off: 'kapalı',
on: 'açık',
dynamic: 'dinamik(Yüksek GPU kullanımı)',
},
closeAppOption: {
text: 'Close App...',
ask: 'Ask',
exit: 'Exit',
minimizeToTray: 'Küçült',
},
unm: {
enable: 'Enable',
audioSource: {
title: 'Audio Sources',
},
enableFlac: {
title: 'Enable FLAC Sources',
desc: 'To take effect, it may be required to clear the cache after enabling this function.',
},
searchMode: {
title: 'Audio Search Mode',
fast: 'Speed Priority',
order: 'Order Priority',
},
cookie: {
joox: 'Cookie for Joox use',
qq: 'Cookie for QQ use',
desc1: 'Click here for the configuration instruction. ',
desc2: 'Leave empty to pick up the default value',
},
ytdl: 'The youtube-dl Executable File for YtDl',
proxy: {
title: 'Proxy Server for UNM',
desc1:
'The proxy server to use for requesting services such as YouTube',
desc2: 'Leave empty to pick up the default value',
},
},
},
contextMenu: {
play: 'Oynat',
addToQueue: 'Sonrakini Oynat',
saveToMyLikedSongs: 'Beğendiğim Müziklere Kaydet',
removeFromMyLikedMüzikler: 'Beğendiğim Müziklerden Kaldır',
saveToLibrary: 'Save to library',
removeFromLibrary: 'Remove from library',
addToPlaylist: 'Add to playlist',
searchInPlaylist: 'Search in playlist',
copyUrl: 'Copy URL',
openInBrowser: 'Open in Browser',
allPlaylists: 'All Playlists',
minePlaylists: 'My Playlists',
likedPlaylists: 'Liked Playlists',
cardiacMode: 'Cardiac Mode',
copyLyric: 'Copy Lyric',
copyLyricWithTranslation: 'Copy Lyric With Translation',
},
toast: {
savedToMyLikedSongs: 'Beğendiğim Müziklere Kaydet',
removedFromMyLikedSongs: 'Beğendiğim Müziklerden Kaldır',
},
};
================================================
FILE: src/locale/lang/zh-CN.js
================================================
export default {
common: {
play: '播放',
songs: '首歌',
},
nav: {
home: '首页',
explore: '发现',
library: '音乐库',
search: '搜索',
github: 'GitHub 仓库',
},
home: {
recommendPlaylist: '推荐歌单',
recommendArtist: '推荐艺人',
newAlbum: '新专速递',
seeMore: '查看全部',
charts: '排行榜',
},
library: {
sLibrary: '的音乐库',
likedSongs: '我喜欢的音乐',
sLikedSongs: '喜欢的音乐',
playlists: '歌单',
albums: '专辑',
artists: '艺人',
mvs: 'MV',
cloudDisk: '云盘',
newPlayList: '新建歌单',
uploadSongs: '上传歌曲',
playHistory: {
title: '听歌排行',
week: '最近一周',
all: '所有时间',
},
userProfileMenu: {
settings: '设置',
logout: '登出',
},
},
explore: {
explore: '发现',
loadMore: '加载更多',
},
artist: {
latestRelease: '最新发布',
latestMV: '最新 MV',
popularSongs: '热门歌曲',
showMore: '显示更多',
showLess: '收起',
EPsSingles: 'EP 和单曲',
albums: '专辑',
withAlbums: '张专辑',
artist: '艺人',
videos: '个 MV',
following: '正在关注',
follow: '关注',
similarArtists: '相似艺人',
artistDesc: '艺术家介绍',
},
album: {
released: '发行于',
albumDesc: '专辑介绍',
},
playlist: {
playlist: '歌单',
updatedAt: '最后更新于',
search: '搜索歌单音乐',
},
login: {
accessToAll: '可访问全部数据',
loginText: '登录网易云账号',
search: '搜索网易云账号',
readonly: '只能读取账号公开数据',
usernameLogin: '用户名登录',
searchHolder: '请输入你的网易云用户名',
enterTip: '按 Enter 搜索',
choose: '在列表中选中你的账号',
confirm: '确认',
countryCode: '国际区号',
phone: '手机号',
email: '邮箱',
password: '密码',
login: '登录',
loginWithEmail: '邮箱登录',
loginWithPhone: '手机号登录',
notice: `YesPlayMusic 承诺不会保存你的任何账号信息到云端。
你的密码会在本地进行 MD5 加密后再传输到网易云 API。
YesPlayMusic 并非网易云官方网站,输入账号信息前请慎重考虑。 你也可以前往
YesPlayMusic 的 GitHub 源代码仓库
自行构建并使用自托管的网易云 API。`,
noticeElectron: `你的密码会在本地进行 MD5 加密后再传输到网易云 API。
YesPlayMusic 不会传输你的账号数据到任何非网易云音乐官方的服务器。
`,
},
mv: {
moreVideo: '更多视频',
},
next: {
nowPlaying: '正在播放',
nextUp: '即将播放',
},
player: {
like: '喜欢',
unlike: '取消喜欢',
previous: '上一首',
next: '下一首',
repeat: '循环播放',
repeatTrack: '单曲循环',
shuffle: '随机播放',
reversed: '倒序播放',
play: '播放',
pause: '暂停',
mute: '静音',
nextUp: '播放列表',
translationLyric: '歌词(译)',
PronunciationLyric: '歌词(音)',
},
modal: {
close: '关闭',
},
search: {
artist: '艺人',
album: '专辑',
song: '歌曲',
mv: '视频',
playlist: '歌单',
noResult: '暂无结果',
searchFor: '搜索',
},
settings: {
settings: '设置',
logout: '登出',
language: '语言',
lyric: '歌词',
others: '其他',
customization: '自定义',
MusicGenrePreference: {
text: '音乐语种偏好',
none: '无偏好',
mandarin: '华语',
western: '欧美',
korean: '韩语',
japanese: '日语',
},
musicQuality: {
text: '音质选择',
low: '普通',
medium: '较高',
high: '极高',
lossless: '无损',
},
cacheLimit: {
text: '歌曲缓存上限',
none: '无限制',
},
lyricFontSize: {
text: '歌词字体大小',
small: '小',
medium: '中',
large: '大(默认)',
xlarge: '超大',
},
deviceSelector: '音频输出设备',
permissionRequired: '需要麦克风权限',
appearance: {
text: '外观',
auto: '自动',
light: '浅色',
dark: '深色',
},
trayIcon: {
text: '托盘图标颜色',
auto: '自动',
light: '浅色',
dark: '深色',
},
automaticallyCacheSongs: '自动缓存歌曲',
clearSongsCache: '清除歌曲缓存',
cacheCount: '已缓存 {song} 首 ({size})',
showLyricsTranslation: '显示歌词翻译',
showPlaylistsByAppleMusic: '首页显示来自 Apple Music 的歌单',
enableDiscordRichPresence: '启用 Discord Rich Presence',
enableGlobalShortcut: '启用全局快捷键',
showLibraryDefault: '启动后显示音乐库',
subTitleDefault: '副标题使用别名',
enableReversedMode: '启用倒序播放功能 (实验性功能)',
enableCustomTitlebar: '启用自定义标题栏 (重启后生效)',
lyricsBackground: {
text: '显示歌词背景',
off: '关闭',
on: '打开',
dynamic: '动态(GPU 占用较高)',
},
showLyricsTime: '显示当前时间',
closeAppOption: {
text: '关闭主面板时...',
ask: '询问',
exit: '退出',
minimizeToTray: '最小化到托盘',
},
enableOsdlyricsSupport: {
title: '桌面歌词支持',
desc1:
'仅 Linux 下生效。启用后会将歌词文件下载到本地,并在开启播放器时尝试拉起 OSDLyrics。',
desc2: '请在开启之前确保您已经正确安装了 OSDLyrics。',
},
unm: {
enable: '启用',
audioSource: {
title: '备选音源',
},
enableFlac: {
title: '启用 FLAC',
desc: '启用后需要清除歌曲缓存才能生效',
},
searchMode: {
title: '音源搜索模式',
fast: '速度优先',
order: '顺序优先',
},
cookie: {
joox: 'Joox 引擎的 Cookie',
qq: 'QQ 引擎的 Cookie',
desc1: '设置说明请参见此处',
desc2: ',留空则不进行相关设置',
},
ytdl: 'YtDl 引擎要使用的 youtube-dl 可执行文件',
proxy: {
title: '用于 UNM 的代理服务器',
desc1: '请求如 YouTube 音源服务时要使用的代理服务器',
desc2: '留空则不进行相关设置',
},
},
},
contextMenu: {
play: '播放',
addToQueue: '添加到队列',
saveToMyLikedSongs: '添加到我喜欢的音乐',
removeFromMyLikedSongs: '从喜欢的音乐中删除',
saveToLibrary: '保存到音乐库',
removeFromLibrary: '从音乐库删除',
addToPlaylist: '添加到歌单',
searchInPlaylist: '歌单内搜索',
copyUrl: '复制链接',
openInBrowser: '在浏览器中打开',
allPlaylists: '全部歌单',
minePlaylists: '创建的歌单',
likedPlaylists: '收藏的歌单',
cardiacMode: '心动模式',
copyLyric: '复制歌词',
copyLyricWithTranslation: '复制歌词(含翻译)',
},
toast: {
savedToPlaylist: '已添加到歌单',
removedFromPlaylist: '已从歌单中删除',
savedToMyLikedSongs: '已添加到我喜欢的音乐',
removedFromMyLikedSongs: '已从喜欢的音乐中删除',
copied: '已复制',
copyFailed: '复制失败:',
needToLogin: '此操作需要登录网易云帐号',
},
};
================================================
FILE: src/locale/lang/zh-TW.js
================================================
export default {
common: {
play: '播放',
songs: '首歌',
},
nav: {
home: '首頁',
explore: '發現',
library: '音樂庫',
search: '搜尋',
github: 'GitHub Repo',
},
home: {
recommendPlaylist: '推薦歌單',
recommendArtist: '推薦藝人',
newAlbum: '新曲上架',
seeMore: '查看全部',
charts: '排行榜',
},
library: {
sLibrary: '的音樂庫',
likedSongs: '我喜歡的音樂',
sLikedSongs: '喜歡的音樂',
playlists: '歌單',
albums: '專輯',
artists: '藝人',
mvs: 'MV',
cloudDisk: '雲端硬碟',
newPlayList: '新增歌單',
uploadSongs: '上傳音樂',
playHistory: {
title: '聽歌排行',
week: '最近一周',
all: '所有時間',
},
userProfileMenu: {
settings: '設定',
logout: '登出',
},
},
explore: {
explore: '探索',
loadMore: '載入更多',
},
artist: {
latestRelease: '最新發佈',
popularSongs: '熱門歌曲',
showMore: '顯示更多',
showLess: '收起',
EPsSingles: 'EP 和單曲',
albums: '專輯',
withAlbums: '張專輯',
artist: '藝人',
videos: '個 MV',
following: '正在追蹤',
follow: '追蹤',
},
album: {
released: '發行於',
},
playlist: {
playlist: '歌單',
updatedAt: '最後更新於',
search: '搜尋歌單內音樂',
},
login: {
accessToAll: '可存取全部資料',
loginText: '登入網易雲帳戶',
search: '搜尋網易雲帳戶',
readonly: '只能讀取帳戶公開資料',
usernameLogin: '使用者名稱登入',
searchHolder: '請輸入您的網易雲使用者名稱',
enterTip: '按 Enter 搜尋',
choose: '在選單中選擇你的帳戶',
confirm: '確認',
countryCode: '國際區碼',
phone: '手機號碼',
email: 'Email',
password: '密碼',
login: '登入',
loginWithEmail: '信箱登入',
loginWithPhone: '手機號碼登入',
notice: `YesPlayMusic 承諾不會保存您的任何帳戶資訊到雲端。
您的密碼會在本地進行 MD5 加密後再傳輸到網易雲 API。
YesPlayMusic 並非網易雲官方網站,輸入帳戶資訊前請慎重考慮。 您也可以前往
YesPlayMusic 的 GitHub 原始碼 Repo
自行編譯並使用自託管的網易雲 API。`,
noticeElectron: `您的密碼會在本地進行 MD5 加密後再傳輸到網易雲 API。
YesPlayMusic 不會傳輸你的帳戶資料到任何非網易雲音樂官方的伺服器。
`,
},
mv: {
moreVideo: '更多影片',
},
next: {
nowPlaying: '正在播放',
nextUp: '即將播放',
},
player: {
like: '喜歡',
unlike: '取消喜歡',
previous: '上一首',
next: '下一首',
repeat: '循環播放',
repeatTrack: '單曲循環',
shuffle: '隨機播放',
reversed: '倒序播放',
play: '播放',
pause: '暫停',
mute: '靜音',
nextUp: '播放清單',
translationLyric: '歌詞(譯)',
PronunciationLyric: '歌詞(音)',
},
modal: {
close: '關閉',
},
search: {
artist: '藝人',
album: '專輯',
song: '歌曲',
mv: '影片',
playlist: '歌單',
noResult: '暫無結果',
searchFor: '搜尋',
},
settings: {
settings: '設定',
logout: '登出',
language: '語言',
lyric: '歌詞',
others: '其他',
customization: '自訂',
MusicGenrePreference: {
text: '音樂語種偏好',
none: '無偏好',
mandarin: '華語',
western: '歐美',
korean: '韓語',
japanese: '日語',
},
musicQuality: {
text: '音質選擇',
low: '普通',
medium: '較高',
high: '極高',
lossless: '無損',
},
cacheLimit: {
text: '歌曲快取上限',
none: '無限制',
},
lyricFontSize: {
text: '歌詞字體大小',
small: '小',
medium: '中',
large: '大(預設)',
xlarge: '超大',
},
deviceSelector: '音訊輸出裝置',
permissionRequired: '需要麥克風權限',
appearance: {
text: '外觀',
auto: '自動',
light: '淺色',
dark: '深色',
},
trayIcon: {
text: '工作列圖示顏色',
auto: '自動',
light: '淺色',
dark: '深色',
},
automaticallyCacheSongs: '自動快取歌曲',
clearSongsCache: '清除歌曲快取',
cacheCount: '已快取 {song} 首 ({size})',
showLyricsTranslation: '顯示歌詞翻譯',
minimizeToTray: '最小化到工作列角落',
showPlaylistsByAppleMusic: '首頁顯示來自 Apple Music 的歌單',
enableDiscordRichPresence: '啟用 Discord Rich Presence',
enableGlobalShortcut: '啟用全域快捷鍵',
showLibraryDefault: '啟動後顯示音樂庫',
subTitleDefault: '副標題使用別名',
enableReversedMode: '啟用倒序播放功能 (實驗性功能)',
enableCustomTitlebar: '啟用自訂標題列(重新啟動後生效)',
showLyricsTime: '顯示目前時間',
lyricsBackground: {
text: '顯示歌詞背景',
off: '關閉',
on: '開啟',
dynamic: '動態(GPU 占用較高)',
},
closeAppOption: {
text: '關閉主面板時...',
ask: '詢問',
exit: '退出',
minimizeToTray: '最小化到工作列角落',
},
enableOsdlyricsSupport: {
title: '桌面歌詞支援',
desc1:
'只在 Linux 環境下生效。啟用後會將歌詞檔案下載至本機位置,並在開啟播放器時嘗試連帶啟動 OSDLyrics。',
desc2: '請在開啟之前確保您已經正確安裝了 OSDLyrics。',
},
unm: {
enable: '啟用',
audioSource: {
title: '備選音源',
},
enableFlac: {
title: '啟用 FLAC',
desc: '啟用後需要清除歌曲快取才能生效',
},
searchMode: {
title: '音源搜尋模式',
fast: '速度優先',
order: '順序優先',
},
cookie: {
joox: 'Joox 引擎的 Cookie',
qq: 'QQ 引擎的 Cookie',
desc1: '設定說明請參見此處',
desc2: ',留空則不進行相關設定',
},
ytdl: 'YtDl 引擎要使用的 youtube-dl 執行檔',
proxy: {
title: '用於 UNM 的 Proxy 伺服器',
desc1: '請求如 YouTube 音源服務時要使用的 Proxy 伺服器',
desc2: '留空則不進行相關設定',
},
},
},
contextMenu: {
play: '播放',
addToQueue: '新增至佇列',
saveToMyLikedSongs: '新增至我喜歡的音樂',
removeFromMyLikedSongs: '從喜歡的音樂中刪除',
saveToLibrary: '新增至音樂庫',
removeFromLibrary: '從音樂庫刪除',
addToPlaylist: '新增至歌單',
searchInPlaylist: '歌單內搜尋',
openInBrowser: '在瀏覽器中打開',
copyUrl: '複製連結',
allPlaylists: '全部歌單',
minePlaylists: '我建立的歌單',
likedPlaylists: '收藏的歌單',
cardiacMode: '心動模式',
copyLyric: '複製歌詞',
copyLyricWithTranslation: '複製歌詞(含翻譯)',
},
toast: {
savedToPlaylist: '已新增至歌單',
removedFromPlaylist: '已從歌單中刪除',
savedToMyLikedSongs: '已新增至我喜歡的音樂',
removedFromMyLikedSongs: '已從喜歡的音樂中刪除',
copied: '已複製',
copyFailed: '複製失敗:',
needToLogin: '此動作需要登入網易雲帳戶',
},
};
================================================
FILE: src/main.js
================================================
import Vue from 'vue';
import VueGtag from 'vue-gtag';
import App from './App.vue';
import router from './router';
import store from './store';
import i18n from '@/locale';
import '@/assets/icons';
import '@/utils/filters';
import './registerServiceWorker';
import { dailyTask } from '@/utils/common';
import '@/assets/css/global.scss';
import NProgress from 'nprogress';
import '@/assets/css/nprogress.css';
window.resetApp = () => {
localStorage.clear();
indexedDB.deleteDatabase('yesplaymusic');
document.cookie.split(';').forEach(function (c) {
document.cookie = c
.replace(/^ +/, '')
.replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
});
return '已重置应用,请刷新页面(按Ctrl/Command + R)';
};
console.log(
'如出现问题,可尝试在本页输入 %cresetApp()%c 然后按回车重置应用。',
'background: #eaeffd;color:#335eea;padding: 4px 6px;border-radius:3px;',
'background:unset;color:unset;'
);
Vue.use(
VueGtag,
{
config: { id: 'G-KMJJCFZDKF' },
},
router
);
Vue.config.productionTip = false;
NProgress.configure({ showSpinner: false, trickleSpeed: 100 });
dailyTask();
new Vue({
i18n,
store,
router,
render: h => h(App),
}).$mount('#app');
================================================
FILE: src/ncmModDef.js
================================================
module.exports = [
{
identifier: 'user_update',
route: '/user/update',
module: require('@neteaseapireborn/api/module/user_update'),
},
{
identifier: 'user_subcount',
route: '/user/subcount',
module: require('@neteaseapireborn/api/module/user_subcount'),
},
{
identifier: 'user_replacephone',
route: '/user/replacephone',
module: require('@neteaseapireborn/api/module/user_replacephone'),
},
{
identifier: 'user_record',
route: '/user/record',
module: require('@neteaseapireborn/api/module/user_record'),
},
{
identifier: 'user_playlist',
route: '/user/playlist',
module: require('@neteaseapireborn/api/module/user_playlist'),
},
{
identifier: 'user_level',
route: '/user/level',
module: require('@neteaseapireborn/api/module/user_level'),
},
{
identifier: 'user_follows',
route: '/user/follows',
module: require('@neteaseapireborn/api/module/user_follows'),
},
{
identifier: 'user_followeds',
route: '/user/followeds',
module: require('@neteaseapireborn/api/module/user_followeds'),
},
{
identifier: 'user_event',
route: '/user/event',
module: require('@neteaseapireborn/api/module/user_event'),
},
{
identifier: 'user_dj',
route: '/user/dj',
module: require('@neteaseapireborn/api/module/user_dj'),
},
{
identifier: 'user_detail',
route: '/user/detail',
module: require('@neteaseapireborn/api/module/user_detail'),
},
{
identifier: 'user_cloud_detail',
route: '/user/cloud/detail',
module: require('@neteaseapireborn/api/module/user_cloud_detail'),
},
{
identifier: 'user_cloud_del',
route: '/user/cloud/del',
module: require('@neteaseapireborn/api/module/user_cloud_del'),
},
{
identifier: 'user_cloud',
route: '/user/cloud',
module: require('@neteaseapireborn/api/module/user_cloud'),
},
{
identifier: 'user_bindingcellphone',
route: '/user/bindingcellphone',
module: require('@neteaseapireborn/api/module/user_bindingcellphone'),
},
{
identifier: 'user_binding',
route: '/user/binding',
module: require('@neteaseapireborn/api/module/user_binding'),
},
{
identifier: 'user_audio',
route: '/user/audio',
module: require('@neteaseapireborn/api/module/user_audio'),
},
{
identifier: 'user_account',
route: '/user/account',
module: require('@neteaseapireborn/api/module/user_account'),
},
{
identifier: 'toplist_detail',
route: '/toplist/detail',
module: require('@neteaseapireborn/api/module/toplist_detail'),
},
{
identifier: 'toplist_artist',
route: '/toplist/artist',
module: require('@neteaseapireborn/api/module/toplist_artist'),
},
{
identifier: 'toplist',
route: '/toplist',
module: require('@neteaseapireborn/api/module/toplist'),
},
{
identifier: 'topic_sublist',
route: '/topic/sublist',
module: require('@neteaseapireborn/api/module/topic_sublist'),
},
{
identifier: 'topic_detail_event_hot',
route: '/topic/detail/event/hot',
module: require('@neteaseapireborn/api/module/topic_detail_event_hot'),
},
{
identifier: 'topic_detail',
route: '/topic/detail',
module: require('@neteaseapireborn/api/module/topic_detail'),
},
{
identifier: 'top_song',
route: '/top/song',
module: require('@neteaseapireborn/api/module/top_song'),
},
{
identifier: 'top_playlist_highquality',
route: '/top/playlist/highquality',
module: require('@neteaseapireborn/api/module/top_playlist_highquality'),
},
{
identifier: 'top_playlist',
route: '/top/playlist',
module: require('@neteaseapireborn/api/module/top_playlist'),
},
{
identifier: 'top_mv',
route: '/top/mv',
module: require('@neteaseapireborn/api/module/top_mv'),
},
{
identifier: 'top_list',
route: '/top/list',
module: require('@neteaseapireborn/api/module/top_list'),
},
{
identifier: 'top_artists',
route: '/top/artists',
module: require('@neteaseapireborn/api/module/top_artists'),
},
{
identifier: 'top_album',
route: '/top/album',
module: require('@neteaseapireborn/api/module/top_album'),
},
{
identifier: 'song_url',
route: '/song/url',
module: require('@neteaseapireborn/api/module/song_url'),
},
{
identifier: 'song_download_url',
route: '/song/download/url',
module: require('@neteaseapireborn/api/module/song_download_url'),
},
{
identifier: 'song_detail',
route: '/song/detail',
module: require('@neteaseapireborn/api/module/song_detail'),
},
{
identifier: 'simi_mv',
route: '/simi/mv',
module: require('@neteaseapireborn/api/module/simi_mv'),
},
{
identifier: 'simi_artist',
route: '/simi/artist',
module: require('@neteaseapireborn/api/module/simi_artist'),
},
{
identifier: 'search',
route: '/search',
module: require('@neteaseapireborn/api/module/search'),
},
{
identifier: 'scrobble',
route: '/scrobble',
module: require('@neteaseapireborn/api/module/scrobble'),
},
{
identifier: 'recommend_songs',
route: '/recommend/songs',
module: require('@neteaseapireborn/api/module/recommend_songs'),
},
{
identifier: 'recommend_resource',
route: '/recommend/resource',
module: require('@neteaseapireborn/api/module/recommend_resource'),
},
{
identifier: 'playmode_intelligence_list',
route: '/playmode/intelligence/list',
module: require('@neteaseapireborn/api/module/playmode_intelligence_list'),
},
{
identifier: 'playlist_video_recent',
route: '/playlist/video/recent',
module: require('@neteaseapireborn/api/module/playlist_video_recent'),
},
{
identifier: 'playlist_update',
route: '/playlist/update',
module: require('@neteaseapireborn/api/module/playlist_update'),
},
{
identifier: 'playlist_tracks',
route: '/playlist/tracks',
module: require('@neteaseapireborn/api/module/playlist_tracks'),
},
{
identifier: 'playlist_track_delete',
route: '/playlist/track/delete',
module: require('@neteaseapireborn/api/module/playlist_track_delete'),
},
{
identifier: 'playlist_track_all',
route: '/playlist/track/all',
module: require('@neteaseapireborn/api/module/playlist_track_all'),
},
{
identifier: 'playlist_track_add',
route: '/playlist/track/add',
module: require('@neteaseapireborn/api/module/playlist_track_add'),
},
{
identifier: 'playlist_tags_update',
route: '/playlist/tags/update',
module: require('@neteaseapireborn/api/module/playlist_tags_update'),
},
{
identifier: 'playlist_subscribers',
route: '/playlist/subscribers',
module: require('@neteaseapireborn/api/module/playlist_subscribers'),
},
{
identifier: 'playlist_subscribe',
route: '/playlist/subscribe',
module: require('@neteaseapireborn/api/module/playlist_subscribe'),
},
{
identifier: 'playlist_privacy',
route: '/playlist/privacy',
module: require('@neteaseapireborn/api/module/playlist_privacy'),
},
{
identifier: 'playlist_order_update',
route: '/playlist/order/update',
module: require('@neteaseapireborn/api/module/playlist_order_update'),
},
{
identifier: 'playlist_name_update',
route: '/playlist/name/update',
module: require('@neteaseapireborn/api/module/playlist_name_update'),
},
{
identifier: 'playlist_mylike',
route: '/playlist/mylike',
module: require('@neteaseapireborn/api/module/playlist_mylike'),
},
{
identifier: 'playlist_hot',
route: '/playlist/hot',
module: require('@neteaseapireborn/api/module/playlist_hot'),
},
{
identifier: 'playlist_highquality_tags',
route: '/playlist/highquality/tags',
module: require('@neteaseapireborn/api/module/playlist_highquality_tags'),
},
{
identifier: 'playlist_detail_dynamic',
route: '/playlist/detail/dynamic',
module: require('@neteaseapireborn/api/module/playlist_detail_dynamic'),
},
{
identifier: 'playlist_detail',
route: '/playlist/detail',
module: require('@neteaseapireborn/api/module/playlist_detail'),
},
{
identifier: 'playlist_desc_update',
route: '/playlist/desc/update',
module: require('@neteaseapireborn/api/module/playlist_desc_update'),
},
{
identifier: 'playlist_delete',
route: '/playlist/delete',
module: require('@neteaseapireborn/api/module/playlist_delete'),
},
{
identifier: 'playlist_create',
route: '/playlist/create',
module: require('@neteaseapireborn/api/module/playlist_create'),
},
{
identifier: 'playlist_cover_update',
route: '/playlist/cover/update',
module: require('@neteaseapireborn/api/module/playlist_cover_update'),
},
{
identifier: 'playlist_catlist',
route: '/playlist/catlist',
module: require('@neteaseapireborn/api/module/playlist_catlist'),
},
{
identifier: 'personalized',
route: '/personalized',
module: require('@neteaseapireborn/api/module/personalized'),
},
{
identifier: 'personal_fm',
route: '/personal_fm',
module: require('@neteaseapireborn/api/module/personal_fm'),
},
{
identifier: 'mv_url',
route: '/mv/url',
module: require('@neteaseapireborn/api/module/mv_url'),
},
{
identifier: 'mv_sublist',
route: '/mv/sublist',
module: require('@neteaseapireborn/api/module/mv_sublist'),
},
{
identifier: 'mv_sub',
route: '/mv/sub',
module: require('@neteaseapireborn/api/module/mv_sub'),
},
{
identifier: 'mv_first',
route: '/mv/first',
module: require('@neteaseapireborn/api/module/mv_first'),
},
{
identifier: 'mv_exclusive_rcmd',
route: '/mv/exclusive/rcmd',
module: require('@neteaseapireborn/api/module/mv_exclusive_rcmd'),
},
{
identifier: 'mv_detail_info',
route: '/mv/detail/info',
module: require('@neteaseapireborn/api/module/mv_detail_info'),
},
{
identifier: 'mv_detail',
route: '/mv/detail',
module: require('@neteaseapireborn/api/module/mv_detail'),
},
{
identifier: 'mv_all',
route: '/mv/all',
module: require('@neteaseapireborn/api/module/mv_all'),
},
{
identifier: 'lyric',
route: '/lyric',
module: require('@neteaseapireborn/api/module/lyric'),
},
{
identifier: 'logout',
route: '/logout',
module: require('@neteaseapireborn/api/module/logout'),
},
{
identifier: 'login_status',
route: '/login/status',
module: require('@neteaseapireborn/api/module/login_status'),
},
{
identifier: 'login_refresh',
route: '/login/refresh',
module: require('@neteaseapireborn/api/module/login_refresh'),
},
{
identifier: 'login_qr_key',
route: '/login/qr/key',
module: require('@neteaseapireborn/api/module/login_qr_key'),
},
{
identifier: 'login_qr_create',
route: '/login/qr/create',
module: require('@neteaseapireborn/api/module/login_qr_create'),
},
{
identifier: 'login_qr_check',
route: '/login/qr/check',
module: require('@neteaseapireborn/api/module/login_qr_check'),
},
{
identifier: 'login_cellphone',
route: '/login/cellphone',
module: require('@neteaseapireborn/api/module/login_cellphone'),
},
{
identifier: 'login',
route: '/login',
module: require('@neteaseapireborn/api/module/login'),
},
{
identifier: 'likelist',
route: '/likelist',
module: require('@neteaseapireborn/api/module/likelist'),
},
{
identifier: 'like',
route: '/like',
module: require('@neteaseapireborn/api/module/like'),
},
{
identifier: 'follow',
route: '/follow',
module: require('@neteaseapireborn/api/module/follow'),
},
{
identifier: 'fm_trash',
route: '/fm_trash',
module: require('@neteaseapireborn/api/module/fm_trash'),
},
{
identifier: 'daily_signin',
route: '/daily_signin',
module: require('@neteaseapireborn/api/module/daily_signin'),
},
{
identifier: 'cloudsearch',
route: '/cloudsearch',
module: require('@neteaseapireborn/api/module/cloudsearch'),
},
{
identifier: 'cloud',
route: '/cloud',
module: require('@neteaseapireborn/api/module/cloud'),
},
{
identifier: 'check_music',
route: '/check/music',
module: require('@neteaseapireborn/api/module/check_music'),
},
{
identifier: 'cellphone_existence_check',
route: '/cellphone/existence/check',
module: require('@neteaseapireborn/api/module/cellphone_existence_check'),
},
{
identifier: 'captcha_verify',
route: '/captcha/verify',
module: require('@neteaseapireborn/api/module/captcha_verify'),
},
{
identifier: 'captcha_sent',
route: '/captcha/sent',
module: require('@neteaseapireborn/api/module/captcha_sent'),
},
{
identifier: 'calendar',
route: '/calendar',
module: require('@neteaseapireborn/api/module/calendar'),
},
{
identifier: 'batch',
route: '/batch',
module: require('@neteaseapireborn/api/module/batch'),
},
{
identifier: 'banner',
route: '/banner',
module: require('@neteaseapireborn/api/module/banner'),
},
{
identifier: 'avatar_upload',
route: '/avatar/upload',
module: require('@neteaseapireborn/api/module/avatar_upload'),
},
{
identifier: 'audio_match',
route: '/audio/match',
module: require('@neteaseapireborn/api/module/audio_match'),
},
{
identifier: 'artists',
route: '/artists',
module: require('@neteaseapireborn/api/module/artists'),
},
{
identifier: 'artist_video',
route: '/artist/video',
module: require('@neteaseapireborn/api/module/artist_video'),
},
{
identifier: 'artist_top_song',
route: '/artist/top/song',
module: require('@neteaseapireborn/api/module/artist_top_song'),
},
{
identifier: 'artist_sublist',
route: '/artist/sublist',
module: require('@neteaseapireborn/api/module/artist_sublist'),
},
{
identifier: 'artist_sub',
route: '/artist/sub',
module: require('@neteaseapireborn/api/module/artist_sub'),
},
{
identifier: 'artist_songs',
route: '/artist/songs',
module: require('@neteaseapireborn/api/module/artist_songs'),
},
{
identifier: 'artist_new_song',
route: '/artist/new/song',
module: require('@neteaseapireborn/api/module/artist_new_song'),
},
{
identifier: 'artist_new_mv',
route: '/artist/new/mv',
module: require('@neteaseapireborn/api/module/artist_new_mv'),
},
{
identifier: 'artist_mv',
route: '/artist/mv',
module: require('@neteaseapireborn/api/module/artist_mv'),
},
{
identifier: 'artist_list',
route: '/artist/list',
module: require('@neteaseapireborn/api/module/artist_list'),
},
{
identifier: 'artist_fans',
route: '/artist/fans',
module: require('@neteaseapireborn/api/module/artist_fans'),
},
{
identifier: 'artist_detail',
route: '/artist/detail',
module: require('@neteaseapireborn/api/module/artist_detail'),
},
{
identifier: 'artist_desc',
route: '/artist/desc',
module: require('@neteaseapireborn/api/module/artist_desc'),
},
{
identifier: 'artist_album',
route: '/artist/album',
module: require('@neteaseapireborn/api/module/artist_album'),
},
{
identifier: 'album_sublist',
route: '/album/sublist',
module: require('@neteaseapireborn/api/module/album_sublist'),
},
{
identifier: 'album_sub',
route: '/album/sub',
module: require('@neteaseapireborn/api/module/album_sub'),
},
{
identifier: 'album_songsaleboard',
route: '/album/songsaleboard',
module: require('@neteaseapireborn/api/module/album_songsaleboard'),
},
{
identifier: 'album_newest',
route: '/album/newest',
module: require('@neteaseapireborn/api/module/album_newest'),
},
{
identifier: 'album_new',
route: '/album/new',
module: require('@neteaseapireborn/api/module/album_new'),
},
{
identifier: 'album_list_style',
route: '/album/list/style',
module: require('@neteaseapireborn/api/module/album_list_style'),
},
{
identifier: 'album_list',
route: '/album/list',
module: require('@neteaseapireborn/api/module/album_list'),
},
{
identifier: 'album_detail_dynamic',
route: '/album/detail/dynamic',
module: require('@neteaseapireborn/api/module/album_detail_dynamic'),
},
{
identifier: 'album_detail',
route: '/album/detail',
module: require('@neteaseapireborn/api/module/album_detail'),
},
{
identifier: 'album',
route: '/album',
module: require('@neteaseapireborn/api/module/album'),
},
{
identifier: 'activate_init_profile',
route: '/activate/init/profile',
module: require('@neteaseapireborn/api/module/activate_init_profile'),
},
];
================================================
FILE: src/registerServiceWorker.js
================================================
/* eslint-disable no-console */
import { register } from 'register-service-worker';
if (!process.env.IS_ELECTRON) {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
// console.log(
// "App is being served from cache by a service worker.\n" +
// "For more details, visit https://goo.gl/AFskqB"
// );
},
registered() {
// console.log("Service worker has been registered.");
},
cached() {
// console.log("Content has been cached for offline use.");
},
updatefound() {
// console.log("New content is downloading.");
},
updated() {
// console.log("New content is available; please refresh.");
},
offline() {
// console.log(
// "No internet connection found. App is running in offline mode."
// );
},
error(error) {
console.error('Error during service worker registration:', error);
},
});
}
================================================
FILE: src/router/index.js
================================================
import Vue from 'vue';
import VueRouter from 'vue-router';
import { isLooseLoggedIn, isAccountLoggedIn } from '@/utils/auth';
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'home',
component: () => import('@/views/home.vue'),
meta: {
keepAlive: true,
savePosition: true,
},
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login.vue'),
},
{
path: '/login/username',
name: 'loginUsername',
component: () => import('@/views/loginUsername.vue'),
},
{
path: '/login/account',
name: 'loginAccount',
component: () => import('@/views/loginAccount.vue'),
},
{
path: '/playlist/:id',
name: 'playlist',
component: () => import('@/views/playlist.vue'),
},
{
path: '/album/:id',
name: 'album',
component: () => import('@/views/album.vue'),
},
{
path: '/artist/:id',
name: 'artist',
component: () => import('@/views/artist.vue'),
meta: {
keepAlive: true,
savePosition: true,
},
},
{
path: '/artist/:id/mv',
name: 'artistMV',
component: () => import('@/views/artistMV.vue'),
meta: {
keepAlive: true,
},
},
{
path: '/mv/:id',
name: 'mv',
component: () => import('@/views/mv.vue'),
},
{
path: '/next',
name: 'next',
component: () => import('@/views/next.vue'),
meta: {
keepAlive: true,
savePosition: true,
},
},
{
path: '/search/:keywords?',
name: 'search',
component: () => import('@/views/search.vue'),
meta: {
keepAlive: true,
},
},
{
path: '/search/:keywords/:type',
name: 'searchType',
component: () => import('@/views/searchType.vue'),
},
{
path: '/new-album',
name: 'newAlbum',
component: () => import('@/views/newAlbum.vue'),
},
{
path: '/explore',
name: 'explore',
component: () => import('@/views/explore.vue'),
meta: {
keepAlive: true,
savePosition: true,
},
},
{
path: '/library',
name: 'library',
component: () => import('@/views/library.vue'),
meta: {
requireLogin: true,
keepAlive: true,
savePosition: true,
},
},
{
path: '/library/liked-songs',
name: 'likedSongs',
component: () => import('@/views/playlist.vue'),
meta: {
requireLogin: true,
},
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/settings.vue'),
},
{
path: '/daily/songs',
name: 'dailySongs',
component: () => import('@/views/dailyTracks.vue'),
meta: {
requireAccountLogin: true,
},
},
{
path: '/lastfm/callback',
name: 'lastfmCallback',
component: () => import('@/views/lastfmCallback.vue'),
},
];
const router = new VueRouter({
mode: process.env.IS_ELECTRON ? 'hash' : 'history',
routes,
});
const originalPush = VueRouter.prototype.push;
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err);
};
router.beforeEach((to, from, next) => {
// 需要登录的逻辑
if (to.meta.requireAccountLogin) {
if (isAccountLoggedIn()) {
next();
} else {
next({ path: '/login/account' });
}
}
if (to.meta.requireLogin) {
if (isLooseLoggedIn()) {
next();
} else {
if (process.env.IS_ELECTRON === true) {
next({ path: '/login/account' });
} else {
next({ path: '/login' });
}
}
} else {
next();
}
});
export default router;
================================================
FILE: src/store/actions.js
================================================
// import store, { state, dispatch, commit } from "@/store";
import { isAccountLoggedIn, isLooseLoggedIn } from '@/utils/auth';
import { likeATrack } from '@/api/track';
import { getPlaylistDetail } from '@/api/playlist';
import { getTrackDetail } from '@/api/track';
import {
userPlaylist,
userPlayHistory,
userLikedSongsIDs,
likedAlbums,
likedArtists,
likedMVs,
cloudDisk,
userAccount,
} from '@/api/user';
export default {
showToast({ state, commit }, text) {
if (state.toast.timer !== null) {
clearTimeout(state.toast.timer);
commit('updateToast', { show: false, text: '', timer: null });
}
commit('updateToast', {
show: true,
text,
timer: setTimeout(() => {
commit('updateToast', {
show: false,
text: state.toast.text,
timer: null,
});
}, 3200),
});
},
likeATrack({ state, commit, dispatch }, id) {
if (!isAccountLoggedIn()) {
dispatch('showToast', '此操作需要登录网易云账号');
return;
}
let like = true;
if (state.liked.songs.includes(id)) like = false;
likeATrack({ id, like })
.then(() => {
if (like === false) {
commit('updateLikedXXX', {
name: 'songs',
data: state.liked.songs.filter(d => d !== id),
});
} else {
let newLikeSongs = state.liked.songs;
newLikeSongs.push(id);
commit('updateLikedXXX', {
name: 'songs',
data: newLikeSongs,
});
}
dispatch('fetchLikedSongsWithDetails');
})
.catch(() => {
dispatch('showToast', '操作失败,专辑下架或版权锁定');
});
},
fetchLikedSongs: ({ state, commit }) => {
if (!isLooseLoggedIn()) return;
if (isAccountLoggedIn()) {
return userLikedSongsIDs({ uid: state.data.user.userId }).then(result => {
if (result.ids) {
commit('updateLikedXXX', {
name: 'songs',
data: result.ids,
});
}
});
} else {
// TODO:搜索ID登录的用户
}
},
fetchLikedSongsWithDetails: ({ state, commit }) => {
return getPlaylistDetail(state.data.likedSongPlaylistID, true).then(
result => {
if (result.playlist?.trackIds?.length === 0) {
return new Promise(resolve => {
resolve();
});
}
return getTrackDetail(
result.playlist.trackIds
.slice(0, 12)
.map(t => t.id)
.join(',')
).then(result => {
commit('updateLikedXXX', {
name: 'songsWithDetails',
data: result.songs,
});
});
}
);
},
fetchLikedPlaylist: ({ state, commit }) => {
if (!isLooseLoggedIn()) return;
if (isAccountLoggedIn()) {
return userPlaylist({
uid: state.data.user?.userId,
limit: 2000, // 最多只加载2000个歌单(等有用户反馈问题再修)
timestamp: new Date().getTime(),
}).then(result => {
if (result.playlist) {
commit('updateLikedXXX', {
name: 'playlists',
data: result.playlist,
});
// 更新用户”喜欢的歌曲“歌单ID
commit('updateData', {
key: 'likedSongPlaylistID',
value: result.playlist[0].id,
});
}
});
} else {
// TODO:搜索ID登录的用户
}
},
fetchLikedAlbums: ({ commit }) => {
if (!isAccountLoggedIn()) return;
return likedAlbums({ limit: 2000 }).then(result => {
if (result.data) {
commit('updateLikedXXX', {
name: 'albums',
data: result.data,
});
}
});
},
fetchLikedArtists: ({ commit }) => {
if (!isAccountLoggedIn()) return;
return likedArtists({ limit: 2000 }).then(result => {
if (result.data) {
commit('updateLikedXXX', {
name: 'artists',
data: result.data,
});
}
});
},
fetchLikedMVs: ({ commit }) => {
if (!isAccountLoggedIn()) return;
return likedMVs({ limit: 1000 }).then(result => {
if (result.data) {
commit('updateLikedXXX', {
name: 'mvs',
data: result.data,
});
}
});
},
fetchCloudDisk: ({ commit }) => {
if (!isAccountLoggedIn()) return;
// FIXME: #1242
return cloudDisk({ limit: 1000 }).then(result => {
if (result.data) {
commit('updateLikedXXX', {
name: 'cloudDisk',
data: result.data,
});
}
});
},
fetchPlayHistory: ({ state, commit }) => {
if (!isAccountLoggedIn()) return;
return Promise.all([
userPlayHistory({ uid: state.data.user?.userId, type: 0 }),
userPlayHistory({ uid: state.data.user?.userId, type: 1 }),
]).then(result => {
const data = {};
const dataType = { 0: 'allData', 1: 'weekData' };
if (result[0] && result[1]) {
for (let i = 0; i < result.length; i++) {
const songData = result[i][dataType[i]].map(item => {
const song = item.song;
song.playCount = item.playCount;
return song;
});
data[[dataType[i]]] = songData;
}
commit('updateLikedXXX', {
name: 'playHistory',
data: data,
});
}
});
},
fetchUserProfile: ({ commit }) => {
if (!isAccountLoggedIn()) return;
return userAccount().then(result => {
if (result.code === 200) {
commit('updateData', { key: 'user', value: result.profile });
}
});
},
};
================================================
FILE: src/store/index.js
================================================
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import mutations from './mutations';
import actions from './actions';
import { changeAppearance } from '@/utils/common';
import Player from '@/utils/Player';
// vuex 自定义插件
import saveToLocalStorage from './plugins/localStorage';
import { getSendSettingsPlugin } from './plugins/sendSettings';
Vue.use(Vuex);
let plugins = [saveToLocalStorage];
if (process.env.IS_ELECTRON === true) {
let sendSettings = getSendSettingsPlugin();
plugins.push(sendSettings);
}
const options = {
state,
mutations,
actions,
plugins,
};
const store = new Vuex.Store(options);
if ([undefined, null].includes(store.state.settings.lang)) {
const defaultLang = 'en';
const langMapper = new Map()
.set('zh', 'zh-CN')
.set('zh-TW', 'zh-TW')
.set('en', 'en')
.set('tr', 'tr');
store.state.settings.lang =
langMapper.get(
langMapper.has(navigator.language)
? navigator.language
: navigator.language.slice(0, 2)
) || defaultLang;
localStorage.setItem('settings', JSON.stringify(store.state.settings));
}
changeAppearance(store.state.settings.appearance);
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (store.state.settings.appearance === 'auto') {
changeAppearance(store.state.settings.appearance);
}
});
let player = new Player();
player = new Proxy(player, {
set(target, prop, val) {
// console.log({ prop, val });
target[prop] = val;
if (prop === '_howler') return true;
target.saveSelfToLocalStorage();
target.sendSelfToIpcMain();
return true;
},
});
store.state.player = player;
export default store;
================================================
FILE: src/store/initLocalStorage.js
================================================
import { playlistCategories } from '@/utils/staticData';
import shortcuts from '@/utils/shortcuts';
console.debug('[debug][initLocalStorage.js]');
const enabledPlaylistCategories = playlistCategories
.filter(c => c.enable)
.map(c => c.name);
let localStorage = {
player: {},
settings: {
lang: null,
musicLanguage: 'all',
appearance: 'auto',
musicQuality: 320000,
lyricFontSize: 28,
outputDevice: 'default',
showPlaylistsByAppleMusic: true,
enableUnblockNeteaseMusic: true,
automaticallyCacheSongs: true,
cacheLimit: 8192,
enableReversedMode: false,
nyancatStyle: false,
showLyricsTranslation: true,
lyricsBackground: true,
enableOsdlyricsSupport: false,
closeAppOption: 'ask',
enableDiscordRichPresence: false,
enableGlobalShortcut: true,
showLibraryDefault: false,
subTitleDefault: false,
linuxEnableCustomTitlebar: false,
trayIconTheme: 'auto',
enabledPlaylistCategories,
proxyConfig: {
protocol: 'noProxy',
server: '',
port: null,
},
enableRealIP: false,
realIP: null,
shortcuts: shortcuts,
},
data: {
user: {},
likedSongPlaylistID: 0,
lastRefreshCookieDate: 0,
loginMode: null,
},
};
if (process.env.IS_ELECTRON === true) {
localStorage.settings.automaticallyCacheSongs = true;
}
export default localStorage;
================================================
FILE: src/store/mutations.js
================================================
import shortcuts from '@/utils/shortcuts';
import cloneDeep from 'lodash/cloneDeep';
export default {
updateLikedXXX(state, { name, data }) {
state.liked[name] = data;
if (name === 'songs') {
state.player.sendSelfToIpcMain();
}
},
changeLang(state, lang) {
state.settings.lang = lang;
},
changeMusicQuality(state, value) {
state.settings.musicQuality = value;
},
changeLyricFontSize(state, value) {
state.settings.lyricFontSize = value;
},
changeOutputDevice(state, deviceId) {
state.settings.outputDevice = deviceId;
},
updateSettings(state, { key, value }) {
state.settings[key] = value;
},
updateData(state, { key, value }) {
state.data[key] = value;
},
togglePlaylistCategory(state, name) {
const index = state.settings.enabledPlaylistCategories.findIndex(
c => c === name
);
if (index !== -1) {
state.settings.enabledPlaylistCategories =
state.settings.enabledPlaylistCategories.filter(c => c !== name);
} else {
state.settings.enabledPlaylistCategories.push(name);
}
},
updateToast(state, toast) {
state.toast = toast;
},
updateModal(state, { modalName, key, value }) {
state.modals[modalName][key] = value;
if (key === 'show') {
// 100ms的延迟是为等待右键菜单blur之后再disableScrolling
value === true
? setTimeout(() => (state.enableScrolling = false), 100)
: (state.enableScrolling = true);
}
},
toggleLyrics(state) {
state.showLyrics = !state.showLyrics;
},
updateDailyTracks(state, dailyTracks) {
state.dailyTracks = dailyTracks;
},
updateLastfm(state, session) {
state.lastfm = session;
},
updateShortcut(state, { id, type, shortcut }) {
let newShortcut = state.settings.shortcuts.find(s => s.id === id);
newShortcut[type] = shortcut;
state.settings.shortcuts = state.settings.shortcuts.map(s => {
if (s.id !== id) return s;
return newShortcut;
});
},
restoreDefaultShortcuts(state) {
state.settings.shortcuts = cloneDeep(shortcuts);
},
enableScrolling(state, status = null) {
state.enableScrolling = status ? status : !state.enableScrolling;
},
updateTitle(state, title) {
state.title = title;
},
};
================================================
FILE: src/store/plugins/localStorage.js
================================================
export default store => {
store.subscribe((mutation, state) => {
// console.log(mutation);
localStorage.setItem('settings', JSON.stringify(state.settings));
localStorage.setItem('data', JSON.stringify(state.data));
});
};
================================================
FILE: src/store/plugins/sendSettings.js
================================================
export function getSendSettingsPlugin() {
const electron = window.require('electron');
const ipcRenderer = electron.ipcRenderer;
return store => {
store.subscribe((mutation, state) => {
// console.log(mutation);
if (mutation.type !== 'updateSettings') return;
ipcRenderer.send('settings', state.settings);
});
};
}
================================================
FILE: src/store/state.js
================================================
import initLocalStorage from './initLocalStorage';
import pkg from '../../package.json';
import updateApp from '@/utils/updateApp';
if (localStorage.getItem('appVersion') === null) {
localStorage.setItem('settings', JSON.stringify(initLocalStorage.settings));
localStorage.setItem('data', JSON.stringify(initLocalStorage.data));
localStorage.setItem('appVersion', pkg.version);
}
updateApp();
export default {
showLyrics: false,
enableScrolling: true,
title: 'YesPlayMusic',
liked: {
songs: [],
songsWithDetails: [], // 只有前12首
playlists: [],
albums: [],
artists: [],
mvs: [],
cloudDisk: [],
playHistory: {
weekData: [],
allData: [],
},
},
contextMenu: {
clickObjectID: 0,
showMenu: false,
},
toast: {
show: false,
text: '',
timer: null,
},
modals: {
addTrackToPlaylistModal: {
show: false,
selectedTrackID: 0,
},
newPlaylistModal: {
show: false,
afterCreateAddTrackID: 0,
},
},
dailyTracks: [],
lastfm: JSON.parse(localStorage.getItem('lastfm')) || {},
player: JSON.parse(localStorage.getItem('player')),
settings: JSON.parse(localStorage.getItem('settings')),
data: JSON.parse(localStorage.getItem('data')),
};
================================================
FILE: src/utils/Player.js
================================================
import { getAlbum } from '@/api/album';
import { getArtist } from '@/api/artist';
import { trackScrobble, trackUpdateNowPlaying } from '@/api/lastfm';
import { fmTrash, personalFM } from '@/api/others';
import { getPlaylistDetail, intelligencePlaylist } from '@/api/playlist';
import { getLyric, getMP3, getTrackDetail, scrobble } from '@/api/track';
import store from '@/store';
import { isAccountLoggedIn } from '@/utils/auth';
import { cacheTrackSource, getTrackSource } from '@/utils/db';
import { isCreateMpris, isCreateTray } from '@/utils/platform';
import { Howl, Howler } from 'howler';
import shuffle from 'lodash/shuffle';
import { decode as base642Buffer } from '@/utils/base64';
const PLAY_PAUSE_FADE_DURATION = 200;
const INDEX_IN_PLAY_NEXT = -1;
/**
* @readonly
* @enum {string}
*/
const UNPLAYABLE_CONDITION = {
PLAY_NEXT_TRACK: 'playNextTrack',
PLAY_PREV_TRACK: 'playPrevTrack',
};
const electron =
process.env.IS_ELECTRON === true ? window.require('electron') : null;
const ipcRenderer =
process.env.IS_ELECTRON === true ? electron.ipcRenderer : null;
const delay = ms =>
new Promise(resolve => {
setTimeout(() => {
resolve('');
}, ms);
});
const excludeSaveKeys = [
'_playing',
'_personalFMLoading',
'_personalFMNextLoading',
];
function setTitle(track) {
document.title = track
? `${track.name} · ${track.ar[0].name} - YesPlayMusic`
: 'YesPlayMusic';
if (isCreateTray) {
ipcRenderer?.send('updateTrayTooltip', document.title);
}
store.commit('updateTitle', document.title);
}
function setTrayLikeState(isLiked) {
if (isCreateTray) {
ipcRenderer?.send('updateTrayLikeState', isLiked);
}
}
export default class {
constructor() {
// 播放器状态
this._playing = false; // 是否正在播放中
this._progress = 0; // 当前播放歌曲的进度
this._enabled = false; // 是否启用Player
this._repeatMode = 'off'; // off | on | one
this._shuffle = false; // true | false
this._reversed = false;
this._volume = 1; // 0 to 1
this._volumeBeforeMuted = 1; // 用于保存静音前的音量
this._personalFMLoading = false; // 是否正在私人FM中加载新的track
this._personalFMNextLoading = false; // 是否正在缓存私人FM的下一首歌曲
// 播放信息
this._list = []; // 播放列表
this._current = 0; // 当前播放歌曲在播放列表里的index
this._shuffledList = []; // 被随机打乱的播放列表,随机播放模式下会使用此播放列表
this._shuffledCurrent = 0; // 当前播放歌曲在随机列表里面的index
this._playlistSource = { type: 'album', id: 123 }; // 当前播放列表的信息
this._currentTrack = { id: 86827685 }; // 当前播放歌曲的详细信息
this._playNextList = []; // 当这个list不为空时,会优先播放这个list的歌
this._isPersonalFM = false; // 是否是私人FM模式
this._personalFMTrack = { id: 0 }; // 私人FM当前歌曲
this._personalFMNextTrack = {
id: 0,
}; // 私人FM下一首歌曲信息(为了快速加载下一首)
/**
* The blob records for cleanup.
*
* @private
* @type {string[]}
*/
this.createdBlobRecords = [];
// howler (https://github.com/goldfire/howler.js)
this._howler = null;
Object.defineProperty(this, '_howler', {
enumerable: false,
});
// init
this._init();
window.yesplaymusic = {};
window.yesplaymusic.player = this;
}
get repeatMode() {
return this._repeatMode;
}
set repeatMode(mode) {
if (this._isPersonalFM) return;
if (!['off', 'on', 'one'].includes(mode)) {
console.warn("repeatMode: invalid args, must be 'on' | 'off' | 'one'");
return;
}
this._repeatMode = mode;
}
get shuffle() {
return this._shuffle;
}
set shuffle(shuffle) {
if (this._isPersonalFM) return;
if (shuffle !== true && shuffle !== false) {
console.warn('shuffle: invalid args, must be Boolean');
return;
}
this._shuffle = shuffle;
if (shuffle) {
this._shuffleTheList();
}
// 同步当前歌曲在列表中的下标
this.current = this.list.indexOf(this.currentTrackID);
}
get reversed() {
return this._reversed;
}
set reversed(reversed) {
if (this._isPersonalFM) return;
if (reversed !== true && reversed !== false) {
console.warn('reversed: invalid args, must be Boolean');
return;
}
console.log('changing reversed to:', reversed);
this._reversed = reversed;
}
get volume() {
return this._volume;
}
set volume(volume) {
this._volume = volume;
this._howler?.volume(volume);
}
get list() {
return this.shuffle ? this._shuffledList : this._list;
}
set list(list) {
this._list = list;
}
get current() {
return this.shuffle ? this._shuffledCurrent : this._current;
}
set current(current) {
if (this.shuffle) {
this._shuffledCurrent = current;
} else {
this._current = current;
}
}
get enabled() {
return this._enabled;
}
get playing() {
return this._playing;
}
get currentTrack() {
return this._currentTrack;
}
get currentTrackID() {
return this._currentTrack?.id ?? 0;
}
get playlistSource() {
return this._playlistSource;
}
get playNextList() {
return this._playNextList;
}
get isPersonalFM() {
return this._isPersonalFM;
}
get personalFMTrack() {
return this._personalFMTrack;
}
get currentTrackDuration() {
const trackDuration = this._currentTrack.dt || 1000;
let duration = ~~(trackDuration / 1000);
return duration > 1 ? duration - 1 : duration;
}
get progress() {
return this._progress;
}
set progress(value) {
if (this._howler) {
this._howler.seek(value);
if (isCreateMpris) {
ipcRenderer?.send('seeked', this._howler.seek());
}
}
}
get isCurrentTrackLiked() {
return store.state.liked.songs.includes(this.currentTrack.id);
}
_init() {
this._loadSelfFromLocalStorage();
this._howler?.volume(this.volume);
if (this._enabled) {
// 恢复当前播放歌曲
this._replaceCurrentTrack(this.currentTrackID, false).then(() => {
this._howler?.seek(localStorage.getItem('playerCurrentTrackTime') ?? 0);
}); // update audio source and init howler
this._initMediaSession();
}
this._setIntervals();
// 初始化私人FM
if (
this._personalFMTrack.id === 0 ||
this._personalFMNextTrack.id === 0 ||
this._personalFMTrack.id === this._personalFMNextTrack.id
) {
personalFM().then(result => {
this._personalFMTrack = result.data[0];
this._personalFMNextTrack = result.data[1];
return this._personalFMTrack;
});
}
}
_setPlaying(isPlaying) {
this._playing = isPlaying;
if (isCreateTray) {
ipcRenderer?.send('updateTrayPlayState', this._playing);
}
}
_setIntervals() {
// 同步播放进度
// TODO: 如果 _progress 在别的地方被改变了,
// 这个定时器会覆盖之前改变的值,是bug
setInterval(() => {
if (this._howler === null) return;
this._progress = this._howler.seek();
localStorage.setItem('playerCurrentTrackTime', this._progress);
if (isCreateMpris) {
ipcRenderer?.send('playerCurrentTrackTime', this._progress);
}
}, 1000);
}
_getNextTrack() {
const next = this._reversed ? this.current - 1 : this.current + 1;
if (this._playNextList.length > 0) {
let trackID = this._playNextList[0];
return [trackID, INDEX_IN_PLAY_NEXT];
}
// 循环模式开启,则重新播放当前模式下的相对的下一首
if (this.repeatMode === 'on') {
if (this._reversed && this.current === 0) {
// 倒序模式,当前歌曲是第一首,则重新播放列表最后一首
return [this.list[this.list.length - 1], this.list.length - 1];
} else if (this.list.length === this.current + 1) {
// 正序模式,当前歌曲是最后一首,则重新播放第一首
return [this.list[0], 0];
}
}
// 返回 [trackID, index]
return [this.list[next], next];
}
_getPrevTrack() {
const next = this._reversed ? this.current + 1 : this.current - 1;
// 循环模式开启,则重新播放当前模式下的相对的下一首
if (this.repeatMode === 'on') {
if (this._reversed && this.current === 0) {
// 倒序模式,当前歌曲是最后一首,则重新播放列表第一首
return [this.list[0], 0];
} else if (this.list.length === this.current + 1) {
// 正序模式,当前歌曲是第一首,则重新播放列表最后一首
return [this.list[this.list.length - 1], this.list.length - 1];
}
}
// 返回 [trackID, index]
return [this.list[next], next];
}
async _shuffleTheList(firstTrackID = this.currentTrackID) {
let list = this._list.filter(tid => tid !== firstTrackID);
if (firstTrackID === 'first') list = this._list;
this._shuffledList = shuffle(list);
if (firstTrackID !== 'first') this._shuffledList.unshift(firstTrackID);
}
async _scrobble(track, time, completed = false) {
console.debug(
`[debug][Player.js] scrobble track 👉 ${track.name} by ${track.ar[0].name} 👉 time:${time} completed: ${completed}`
);
const trackDuration = ~~(track.dt / 1000);
time = completed ? trackDuration : ~~time;
scrobble({
id: track.id,
sourceid: this.playlistSource.id,
time,
});
if (
store.state.lastfm.key !== undefined &&
(time >= trackDuration / 2 || time >= 240)
) {
const timestamp = ~~(new Date().getTime() / 1000) - time;
trackScrobble({
artist: track.ar[0].name,
track: track.name,
timestamp,
album: track.al.name,
trackNumber: track.no,
duration: trackDuration,
});
}
}
_playAudioSource(source, autoplay = true) {
Howler.unload();
this._howler = new Howl({
src: [source],
html5: true,
preload: true,
format: ['mp3', 'flac'],
onend: () => {
this._nextTrackCallback();
},
});
this._howler.on('loaderror', (_, errCode) => {
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
// code 3: MEDIA_ERR_DECODE
if (errCode === 3) {
this._playNextTrack(this._isPersonalFM);
} else if (errCode === 4) {
// code 4: MEDIA_ERR_SRC_NOT_SUPPORTED
store.dispatch('showToast', `无法播放: 不支持的音频格式`);
this._playNextTrack(this._isPersonalFM);
} else {
const t = this.progress;
this._replaceCurrentTrackAudio(this.currentTrack, false, false).then(
replaced => {
// 如果 replaced 为 false,代表当前的 track 已经不是这里想要替换的track
// 此时则不修改当前的歌曲进度
if (replaced) {
this._howler?.seek(t);
this.play();
}
}
);
}
});
if (autoplay) {
this.play();
if (this._currentTrack.name) {
setTitle(this._currentTrack);
}
setTrayLikeState(store.state.liked.songs.includes(this.currentTrack.id));
}
this.setOutputDevice();
}
_getAudioSourceBlobURL(data) {
// Create a new object URL.
const source = URL.createObjectURL(new Blob([data]));
// Clean up the previous object URLs since we've created a new one.
// Revoke object URLs can release the memory taken by a Blob,
// which occupied a large proportion of memory.
for (const url in this.createdBlobRecords) {
URL.revokeObjectURL(url);
}
// Then, we replace the createBlobRecords with new one with
// our newly created object URL.
this.createdBlobRecords = [source];
return source;
}
_getAudioSourceFromCache(id) {
return getTrackSource(id).then(t => {
if (!t) return null;
return this._getAudioSourceBlobURL(t.source);
});
}
_getAudioSourceFromNetease(track) {
if (isAccountLoggedIn()) {
return getMP3(track.id).then(result => {
if (!result.data[0]) return null;
if (!result.data[0].url) return null;
if (result.data[0].freeTrialInfo !== null) return null; // 跳过只能试听的歌曲
const source = result.data[0].url.replace(/^http:/, 'https:');
if (store.state.settings.automaticallyCacheSongs) {
cacheTrackSource(track, source, result.data[0].br);
}
return source;
});
} else {
return new Promise(resolve => {
resolve(`https://music.163.com/song/media/outer/url?id=${track.id}`);
});
}
}
async _getAudioSourceFromUnblockMusic(track) {
console.debug(`[debug][Player.js] _getAudioSourceFromUnblockMusic`);
if (
process.env.IS_ELECTRON !== true ||
store.state.settings.enableUnblockNeteaseMusic === false
) {
return null;
}
/**
*
* @param {string=} searchMode
* @returns {import("@unblockneteasemusic/rust-napi").SearchMode}
*/
const determineSearchMode = searchMode => {
/**
* FastFirst = 0
* OrderFirst = 1
*/
switch (searchMode) {
case 'fast-first':
return 0;
case 'order-first':
return 1;
default:
return 0;
}
};
const retrieveSongInfo = await ipcRenderer.invoke(
'unblock-music',
store.state.settings.unmSource,
track,
{
enableFlac: store.state.settings.unmEnableFlac || null,
proxyUri: store.state.settings.unmProxyUri || null,
searchMode: determineSearchMode(store.state.settings.unmSearchMode),
config: {
'joox:cookie': store.state.settings.unmJooxCookie || null,
'qq:cookie': store.state.settings.unmQQCookie || null,
'ytdl:exe': store.state.settings.unmYtDlExe || null,
},
}
);
if (store.state.settings.automaticallyCacheSongs && retrieveSongInfo?.url) {
// 对于来自 bilibili 的音源
// retrieveSongInfo.url 是音频数据的base64编码
// 其他音源为实际url
const url =
retrieveSongInfo.source === 'bilibili'
? `data:application/octet-stream;base64,${retrieveSongInfo.url}`
: retrieveSongInfo.url;
cacheTrackSource(track, url, 128000, `unm:${retrieveSongInfo.source}`);
}
if (!retrieveSongInfo) {
return null;
}
if (retrieveSongInfo.source !== 'bilibili') {
return retrieveSongInfo.url;
}
const buffer = base642Buffer(retrieveSongInfo.url);
return this._getAudioSourceBlobURL(buffer);
}
_getAudioSource(track) {
return this._getAudioSourceFromCache(String(track.id))
.then(source => {
return source ?? this._getAudioSourceFromNetease(track);
})
.then(source => {
return source ?? this._getAudioSourceFromUnblockMusic(track);
});
}
_replaceCurrentTrack(
id,
autoplay = true,
ifUnplayableThen = UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK
) {
if (autoplay && this._currentTrack.name) {
this._scrobble(this.currentTrack, this._howler?.seek());
}
return getTrackDetail(id).then(data => {
const track = data.songs[0];
this._currentTrack = track;
this._updateMediaSessionMetaData(track);
return this._replaceCurrentTrackAudio(
track,
autoplay,
true,
ifUnplayableThen
);
});
}
/**
* @returns 是否成功加载音频,并使用加载完成的音频替换了howler实例
*/
_replaceCurrentTrackAudio(
track,
autoplay,
isCacheNextTrack,
ifUnplayableThen = UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK
) {
return this._getAudioSource(track).then(source => {
if (source) {
let replaced = false;
if (track.id === this.currentTrackID) {
this._playAudioSource(source, autoplay);
replaced = true;
}
if (isCacheNextTrack) {
this._cacheNextTrack();
}
return replaced;
} else {
store.dispatch('showToast', `无法播放 ${track.name}`);
switch (ifUnplayableThen) {
case UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK:
this._playNextTrack(this.isPersonalFM);
break;
case UNPLAYABLE_CONDITION.PLAY_PREV_TRACK:
this.playPrevTrack();
break;
default:
store.dispatch(
'showToast',
`undefined Unplayable condition: ${ifUnplayableThen}`
);
break;
}
return false;
}
});
}
_cacheNextTrack() {
let nextTrackID = this._isPersonalFM
? this._personalFMNextTrack?.id ?? 0
: this._getNextTrack()[0];
if (!nextTrackID) return;
if (this._personalFMTrack.id == nextTrackID) return;
getTrackDetail(nextTrackID).then(data => {
let track = data.songs[0];
this._getAudioSource(track);
});
}
_loadSelfFromLocalStorage() {
const player = JSON.parse(localStorage.getItem('player'));
if (!player) return;
for (const [key, value] of Object.entries(player)) {
this[key] = value;
}
}
_initMediaSession() {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
this.play();
});
navigator.mediaSession.setActionHandler('pause', () => {
this.pause();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
this.playPrevTrack();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
this._playNextTrack(this.isPersonalFM);
});
navigator.mediaSession.setActionHandler('stop', () => {
this.pause();
});
navigator.mediaSession.setActionHandler('seekto', event => {
this.seek(event.seekTime);
this._updateMediaSessionPositionState();
});
navigator.mediaSession.setActionHandler('seekbackward', event => {
this.seek(this.seek() - (event.seekOffset || 10));
this._updateMediaSessionPositionState();
});
navigator.mediaSession.setActionHandler('seekforward', event => {
this.seek(this.seek() + (event.seekOffset || 10));
this._updateMediaSessionPositionState();
});
}
}
_updateMediaSessionMetaData(track) {
if ('mediaSession' in navigator === false) {
return;
}
let artists = track.ar.map(a => a.name);
const metadata = {
title: track.name,
artist: artists.join(','),
album: track.al.name,
artwork: [
{
src: track.al.picUrl + '?param=224y224',
type: 'image/jpg',
sizes: '224x224',
},
{
src: track.al.picUrl + '?param=512y512',
type: 'image/jpg',
sizes: '512x512',
},
],
length: this.currentTrackDuration,
trackId: this.current,
url: '/trackid/' + track.id,
};
navigator.mediaSession.metadata = new window.MediaMetadata(metadata);
if (isCreateMpris) {
this._updateMprisState(track, metadata);
}
}
// OSDLyrics 会检测 Mpris 状态并寻找对应歌词文件,所以要在更新 Mpris 状态之前保证歌词下载完成
async _updateMprisState(track, metadata) {
if (!store.state.settings.enableOsdlyricsSupport) {
return ipcRenderer?.send('metadata', metadata);
}
let lyricContent = await getLyric(track.id);
if (!lyricContent.lrc || !lyricContent.lrc.lyric) {
return ipcRenderer?.send('metadata', metadata);
}
ipcRenderer.send('sendLyrics', {
track,
lyrics: lyricContent.lrc.lyric,
});
ipcRenderer.on('saveLyricFinished', () => {
ipcRenderer?.send('metadata', metadata);
});
}
_updateMediaSessionPositionState() {
if ('mediaSession' in navigator === false) {
return;
}
if ('setPositionState' in navigator.mediaSession) {
navigator.mediaSession.setPositionState({
duration: ~~(this.currentTrack.dt / 1000),
playbackRate: 1.0,
position: this.seek(),
});
}
}
_nextTrackCallback() {
this._scrobble(this._currentTrack, 0, true);
if (!this.isPersonalFM && this.repeatMode === 'one') {
this._replaceCurrentTrack(this.currentTrackID);
} else {
this._playNextTrack(this.isPersonalFM);
}
}
_loadPersonalFMNextTrack() {
if (this._personalFMNextLoading) {
return [false, undefined];
}
this._personalFMNextLoading = true;
return personalFM()
.then(result => {
if (!result || !result.data) {
this._personalFMNextTrack = undefined;
} else {
this._personalFMNextTrack = result.data[0];
this._cacheNextTrack(); // cache next track
}
this._personalFMNextLoading = false;
return [true, this._personalFMNextTrack];
})
.catch(() => {
this._personalFMNextTrack = undefined;
this._personalFMNextLoading = false;
return [false, this._personalFMNextTrack];
});
}
_playDiscordPresence(track, seekTime = 0) {
if (
process.env.IS_ELECTRON !== true ||
store.state.settings.enableDiscordRichPresence === false
) {
return null;
}
let copyTrack = { ...track };
copyTrack.dt -= seekTime * 1000;
ipcRenderer?.send('playDiscordPresence', copyTrack);
}
_pauseDiscordPresence(track) {
if (
process.env.IS_ELECTRON !== true ||
store.state.settings.enableDiscordRichPresence === false
) {
return null;
}
ipcRenderer?.send('pauseDiscordPresence', track);
}
_playNextTrack(isPersonal) {
if (isPersonal) {
this.playNextFMTrack();
} else {
this.playNextTrack();
}
}
appendTrack(trackID) {
this.list.append(trackID);
}
playNextTrack() {
// TODO: 切换歌曲时增加加载中的状态
const [trackID, index] = this._getNextTrack();
if (trackID === undefined) {
this._howler?.stop();
this._setPlaying(false);
return false;
}
let next = index;
if (index === INDEX_IN_PLAY_NEXT) {
this._playNextList.shift();
next = this.current;
}
this.current = next;
this._replaceCurrentTrack(trackID);
return true;
}
async playNextFMTrack() {
if (this._personalFMLoading) {
return false;
}
this._isPersonalFM = true;
if (!this._personalFMNextTrack) {
this._personalFMLoading = true;
let result = null;
let retryCount = 5;
for (; retryCount >= 0; retryCount--) {
result = await personalFM().catch(() => null);
if (!result) {
this._personalFMLoading = false;
store.dispatch('showToast', 'personal fm timeout');
return false;
}
if (result.data?.length > 0) {
break;
} else if (retryCount > 0) {
await delay(1000);
}
}
this._personalFMLoading = false;
if (retryCount < 0) {
let content = '获取私人FM数据时重试次数过多,请手动切换下一首';
store.dispatch('showToast', content);
console.log(content);
return false;
}
// 这里只能拿到一条数据
this._personalFMTrack = result.data[0];
} else {
if (this._personalFMNextTrack.id === this._personalFMTrack.id) {
return false;
}
this._personalFMTrack = this._personalFMNextTrack;
}
if (this._isPersonalFM) {
this._replaceCurrentTrack(this._personalFMTrack.id);
}
this._loadPersonalFMNextTrack();
return true;
}
playPrevTrack() {
const [trackID, index] = this._getPrevTrack();
if (trackID === undefined) return false;
this.current = index;
this._replaceCurrentTrack(
trackID,
true,
UNPLAYABLE_CONDITION.PLAY_PREV_TRACK
);
return true;
}
saveSelfToLocalStorage() {
let player = {};
for (let [key, value] of Object.entries(this)) {
if (excludeSaveKeys.includes(key)) continue;
player[key] = value;
}
localStorage.setItem('player', JSON.stringify(player));
}
pause() {
this._howler?.fade(this.volume, 0, PLAY_PAUSE_FADE_DURATION);
this._howler?.once('fade', () => {
this._howler?.pause();
this._setPlaying(false);
setTitle(null);
this._pauseDiscordPresence(this._currentTrack);
});
}
play() {
if (this._howler?.playing()) return;
this._howler?.play();
this._howler?.once('play', () => {
this._howler?.fade(0, this.volume, PLAY_PAUSE_FADE_DURATION);
// 播放时确保开启player.
// 避免因"忘记设置"导致在播放时播放器不显示的Bug
this._enabled = true;
this._setPlaying(true);
if (this._currentTrack.name) {
setTitle(this._currentTrack);
}
this._playDiscordPresence(this._currentTrack, this.seek());
if (store.state.lastfm.key !== undefined) {
trackUpdateNowPlaying({
artist: this.currentTrack.ar[0].name,
track: this.currentTrack.name,
album: this.currentTrack.al.name,
trackNumber: this.currentTrack.no,
duration: ~~(this.currentTrack.dt / 1000),
});
}
});
}
playOrPause() {
if (this._howler?.playing()) {
this.pause();
} else {
this.play();
}
}
seek(time = null, sendMpris = true) {
if (isCreateMpris && sendMpris && time) {
ipcRenderer?.send('seeked', time);
}
if (time !== null) {
this._howler?.seek(time);
if (this._playing)
this._playDiscordPresence(this._currentTrack, this.seek(null, false));
}
return this._howler === null ? 0 : this._howler.seek();
}
mute() {
if (this.volume === 0) {
this.volume = this._volumeBeforeMuted;
} else {
this._volumeBeforeMuted = this.volume;
this.volume = 0;
}
}
setOutputDevice() {
if (this._howler?._sounds.length <= 0 || !this._howler?._sounds[0]._node) {
return;
}
this._howler?._sounds[0]._node.setSinkId(store.state.settings.outputDevice);
}
replacePlaylist(
trackIDs,
playlistSourceID,
playlistSourceType,
autoPlayTrackID = 'first'
) {
this._isPersonalFM = false;
this.list = trackIDs;
this.current = 0;
this._playlistSource = {
type: playlistSourceType,
id: playlistSourceID,
};
if (this.shuffle) this._shuffleTheList(autoPlayTrackID);
if (autoPlayTrackID === 'first') {
this._replaceCurrentTrack(this.list[0]);
} else {
this.current = this.list.indexOf(autoPlayTrackID);
this._replaceCurrentTrack(autoPlayTrackID);
}
}
playAlbumByID(id, trackID = 'first') {
getAlbum(id).then(data => {
let trackIDs = data.songs.map(t => t.id);
this.replacePlaylist(trackIDs, id, 'album', trackID);
});
}
playPlaylistByID(id, trackID = 'first', noCache = false) {
console.debug(
`[debug][Player.js] playPlaylistByID 👉 id:${id} trackID:${trackID} noCache:${noCache}`
);
getPlaylistDetail(id, noCache).then(data => {
let trackIDs = data.playlist.trackIds.map(t => t.id);
this.replacePlaylist(trackIDs, id, 'playlist', trackID);
});
}
playArtistByID(id, trackID = 'first') {
getArtist(id).then(data => {
let trackIDs = data.hotSongs.map(t => t.id);
this.replacePlaylist(trackIDs, id, 'artist', trackID);
});
}
playTrackOnListByID(id, listName = 'default') {
if (listName === 'default') {
this._current = this._list.findIndex(t => t === id);
}
this._replaceCurrentTrack(id);
}
playIntelligenceListById(id, trackID = 'first', noCache = false) {
getPlaylistDetail(id, noCache).then(data => {
const randomId = Math.floor(
Math.random() * (data.playlist.trackIds.length + 1)
);
const songId = data.playlist.trackIds[randomId].id;
intelligencePlaylist({ id: songId, pid: id }).then(result => {
let trackIDs = result.data.map(t => t.id);
this.replacePlaylist(trackIDs, id, 'playlist', trackID);
});
});
}
addTrackToPlayNext(trackID, playNow = false) {
this._playNextList.push(trackID);
if (playNow) {
this.playNextTrack();
}
}
playPersonalFM() {
this._isPersonalFM = true;
if (this.currentTrackID !== this._personalFMTrack.id) {
this._replaceCurrentTrack(this._personalFMTrack.id, true);
} else {
this.playOrPause();
}
}
async moveToFMTrash() {
this._isPersonalFM = true;
let id = this._personalFMTrack.id;
if (await this.playNextFMTrack()) {
fmTrash(id);
}
}
sendSelfToIpcMain() {
if (process.env.IS_ELECTRON !== true) return false;
let liked = store.state.liked.songs.includes(this.currentTrack.id);
ipcRenderer?.send('player', {
playing: this.playing,
likedCurrentTrack: liked,
});
setTrayLikeState(liked);
}
switchRepeatMode() {
if (this._repeatMode === 'on') {
this.repeatMode = 'one';
} else if (this._repeatMode === 'one') {
this.repeatMode = 'off';
} else {
this.repeatMode = 'on';
}
if (isCreateMpris) {
ipcRenderer?.send('switchRepeatMode', this.repeatMode);
}
}
switchShuffle() {
this.shuffle = !this.shuffle;
if (isCreateMpris) {
ipcRenderer?.send('switchShuffle', this.shuffle);
}
}
switchReversed() {
this.reversed = !this.reversed;
}
clearPlayNextList() {
this._playNextList = [];
}
removeTrackFromQueue(index) {
this._playNextList.splice(index, 1);
}
}
================================================
FILE: src/utils/auth.js
================================================
import Cookies from 'js-cookie';
import { logout } from '@/api/auth';
import store from '@/store';
export function setCookies(string) {
const cookies = string.split(';;');
cookies.map(cookie => {
document.cookie = cookie;
const cookieKeyValue = cookie.split(';')[0].split('=');
localStorage.setItem(`cookie-${cookieKeyValue[0]}`, cookieKeyValue[1]);
});
}
export function getCookie(key) {
return Cookies.get(key) ?? localStorage.getItem(`cookie-${key}`);
}
export function removeCookie(key) {
Cookies.remove(key);
localStorage.removeItem(`cookie-${key}`);
}
// MUSIC_U 只有在账户登录的情况下才有
export function isLoggedIn() {
return getCookie('MUSIC_U') !== undefined;
}
// 账号登录
export function isAccountLoggedIn() {
return (
getCookie('MUSIC_U') !== undefined &&
store.state.data.loginMode === 'account'
);
}
// 用户名搜索(用户数据为只读)
export function isUsernameLoggedIn() {
return store.state.data.loginMode === 'username';
}
// 账户登录或者用户名搜索都判断为登录,宽松检查
export function isLooseLoggedIn() {
return isAccountLoggedIn() || isUsernameLoggedIn();
}
export function doLogout() {
logout();
removeCookie('MUSIC_U');
removeCookie('__csrf');
// 更新状态仓库中的用户信息
store.commit('updateData', { key: 'user', value: {} });
// 更新状态仓库中的登录状态
store.commit('updateData', { key: 'loginMode', value: null });
// 更新状态仓库中的喜欢列表
store.commit('updateData', { key: 'likedSongPlaylistID', value: undefined });
}
================================================
FILE: src/utils/base64.js
================================================
// https://github.com/niklasvh/base64-arraybuffer/blob/master/src/index.ts
// Copyright (c) 2012 Niklas von Hertzen Licensed under the MIT license.
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Use a lookup table to find the index.
const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
export const encode = arraybuffer => {
let bytes = new Uint8Array(arraybuffer),
i,
len = bytes.length,
base64 = '';
for (i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if (len % 3 === 2) {
base64 = base64.substring(0, base64.length - 1) + '=';
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + '==';
}
return base64;
};
export const decode = base64 => {
let bufferLength = base64.length * 0.75,
len = base64.length,
i,
p = 0,
encoded1,
encoded2,
encoded3,
encoded4;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i + 1)];
encoded3 = lookup[base64.charCodeAt(i + 2)];
encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};
================================================
FILE: src/utils/checkAuthToken.js
================================================
import os from 'os';
import fs from 'fs';
import path from 'path';
// extract from NeteasyCloudMusicAPI/generateConfig.js and avoid bugs in there (generateConfig require main.js but the main.js has bugs)
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}
================================================
FILE: src/utils/common.js
================================================
import { isAccountLoggedIn } from './auth';
import { refreshCookie } from '@/api/auth';
import dayjs from 'dayjs';
import store from '@/store';
export function isTrackPlayable(track) {
let result = {
playable: true,
reason: '',
};
if (track?.privilege?.pl > 0) {
return result;
}
// cloud storage judgement logic
if (isAccountLoggedIn() && track?.privilege?.cs) {
return result;
}
if (track.fee === 1 || track.privilege?.fee === 1) {
if (isAccountLoggedIn() && store.state.data.user.vipType === 11) {
result.playable = true;
} else {
result.playable = false;
result.reason = 'VIP Only';
}
} else if (track.fee === 4 || track.privilege?.fee === 4) {
result.playable = false;
result.reason = '付费专辑';
} else if (
track.noCopyrightRcmd !== null &&
track.noCopyrightRcmd !== undefined
) {
result.playable = false;
result.reason = '无版权';
} else if (track.privilege?.st < 0 && isAccountLoggedIn()) {
result.playable = false;
result.reason = '已下架';
}
return result;
}
export function mapTrackPlayableStatus(tracks, privileges = []) {
if (tracks?.length === undefined) return tracks;
return tracks.map(t => {
const privilege = privileges.find(item => item.id === t.id) || {};
if (t.privilege) {
Object.assign(t.privilege, privilege);
} else {
t.privilege = privilege;
}
let result = isTrackPlayable(t);
t.playable = result.playable;
t.reason = result.reason;
return t;
});
}
export function randomNum(minNum, maxNum) {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * minNum + 1, 10);
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
default:
return 0;
}
}
export function shuffleAList(list) {
let sortsList = list.map(t => t.sort);
for (let i = 1; i < sortsList.length; i++) {
const random = Math.floor(Math.random() * (i + 1));
[sortsList[i], sortsList[random]] = [sortsList[random], sortsList[i]];
}
let newSorts = {};
list.map(track => {
newSorts[track.id] = sortsList.pop();
});
return newSorts;
}
export function throttle(fn, time) {
let isRun = false;
return function () {
if (isRun) return;
isRun = true;
fn.apply(this, arguments);
setTimeout(() => {
isRun = false;
}, time);
};
}
export function updateHttps(url) {
if (!url) return '';
return url.replace(/^http:/, 'https:');
}
export function dailyTask() {
let lastDate = store.state.data.lastRefreshCookieDate;
if (
isAccountLoggedIn() &&
(lastDate === undefined || lastDate !== dayjs().date())
) {
console.debug('[debug][common.js] execute dailyTask');
refreshCookie().then(() => {
console.debug('[debug][common.js] 刷新cookie');
store.commit('updateData', {
key: 'lastRefreshCookieDate',
value: dayjs().date(),
});
});
}
}
export function changeAppearance(appearance) {
if (appearance === 'auto' || appearance === undefined) {
appearance = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.body.setAttribute('data-theme', appearance);
document
.querySelector('meta[name="theme-color"]')
.setAttribute('content', appearance === 'dark' ? '#222' : '#fff');
}
export function splitSoundtrackAlbumTitle(title) {
let keywords = [
'Music from the Original Motion Picture Score',
'The Original Motion Picture Soundtrack',
'Original MGM Motion Picture Soundtrack',
'Complete Original Motion Picture Score',
'Original Music From The Motion Picture',
'Music From The Disney+ Original Movie',
'Original Music From The Netflix Film',
'Original Score to the Motion Picture',
'Original Motion Picture Soundtrack',
'Soundtrack from the Motion Picture',
'Original Television Soundtrack',
'Original Motion Picture Score',
'Music From the Motion Picture',
'Music From The Motion Picture',
'Complete Motion Picture Score',
'Music from the Motion Picture',
'Original Videogame Soundtrack',
'La Bande Originale du Film',
'Music from the Miniseries',
'Bande Originale du Film',
'Die Original Filmmusik',
'Original Soundtrack',
'Complete Score',
'Original Score',
];
for (let keyword of keywords) {
if (title.includes(keyword) === false) continue;
return {
title: title
.replace(`(${keyword})`, '')
.replace(`: ${keyword}`, '')
.replace(`[${keyword}]`, '')
.replace(`- ${keyword}`, '')
.replace(`${keyword}`, ''),
subtitle: keyword,
};
}
return {
title: title,
subtitle: '',
};
}
export function splitAlbumTitle(title) {
let keywords = [
'Bonus Tracks Edition',
'Complete Edition',
'Deluxe Edition',
'Deluxe Version',
'Tour Edition',
];
for (let keyword of keywords) {
if (title.includes(keyword) === false) continue;
return {
title: title
.replace(`(${keyword})`, '')
.replace(`: ${keyword}`, '')
.replace(`[${keyword}]`, '')
.replace(`- ${keyword}`, '')
.replace(`${keyword}`, ''),
subtitle: keyword,
};
}
return {
title: title,
subtitle: '',
};
}
export function bytesToSize(bytes) {
let marker = 1024; // Change to 1000 if required
let decimal = 2; // Change as required
let kiloBytes = marker;
let megaBytes = marker * marker;
let gigaBytes = marker * marker * marker;
let lang = store.state.settings.lang;
if (bytes < kiloBytes) return bytes + (lang === 'en' ? ' Bytes' : '字节');
else if (bytes < megaBytes)
return (bytes / kiloBytes).toFixed(decimal) + ' KB';
else if (bytes < gigaBytes)
return (bytes / megaBytes).toFixed(decimal) + ' MB';
else return (bytes / gigaBytes).toFixed(decimal) + ' GB';
}
export function formatTrackTime(value) {
if (!value) return '';
let min = ~~(value / 60);
let sec = (~~(value % 60)).toString().padStart(2, '0');
return `${min}:${sec}`;
}
================================================
FILE: src/utils/db.js
================================================
import axios from 'axios';
import Dexie from 'dexie';
import store from '@/store';
// import pkg from "../../package.json";
const db = new Dexie('yesplaymusic');
db.version(4).stores({
trackDetail: '&id, updateTime',
lyric: '&id, updateTime',
album: '&id, updateTime',
});
db.version(3)
.stores({
trackSources: '&id, createTime',
})
.upgrade(tx =>
tx
.table('trackSources')
.toCollection()
.modify(
track => !track.createTime && (track.createTime = new Date().getTime())
)
);
db.version(1).stores({
trackSources: '&id',
});
let tracksCacheBytes = 0;
async function deleteExcessCache() {
if (
store.state.settings.cacheLimit === false ||
tracksCacheBytes < store.state.settings.cacheLimit * Math.pow(1024, 2)
) {
return;
}
try {
const delCache = await db.trackSources.orderBy('createTime').first();
await db.trackSources.delete(delCache.id);
tracksCacheBytes -= delCache.source.byteLength;
console.debug(
`[debug][db.js] deleteExcessCacheSucces, track: ${delCache.name}, size: ${delCache.source.byteLength}, cacheSize:${tracksCacheBytes}`
);
deleteExcessCache();
} catch (error) {
console.debug('[debug][db.js] deleteExcessCacheFailed', error);
}
}
export function cacheTrackSource(trackInfo, url, bitRate, from = 'netease') {
if (!process.env.IS_ELECTRON) return;
const name = trackInfo.name;
const artist =
(trackInfo.ar && trackInfo.ar[0]?.name) ||
(trackInfo.artists && trackInfo.artists[0]?.name) ||
'Unknown';
let cover = trackInfo.al.picUrl;
if (cover.slice(0, 5) !== 'https') {
cover = 'https' + cover.slice(4);
}
axios.get(`${cover}?param=512y512`);
axios.get(`${cover}?param=224y224`);
axios.get(`${cover}?param=1024y1024`);
return axios
.get(url, {
responseType: 'arraybuffer',
})
.then(response => {
db.trackSources.put({
id: trackInfo.id,
source: response.data,
bitRate,
from,
name,
artist,
createTime: new Date().getTime(),
});
console.debug(`[debug][db.js] cached track 👉 ${name} by ${artist}`);
tracksCacheBytes += response.data.byteLength;
deleteExcessCache();
return { trackID: trackInfo.id, source: response.data, bitRate };
});
}
export function getTrackSource(id) {
return db.trackSources.get(Number(id)).then(track => {
if (!track) return null;
console.debug(
`[debug][db.js] get track from cache 👉 ${track.name} by ${track.artist}`
);
return track;
});
}
export function cacheTrackDetail(track, privileges) {
db.trackDetail.put({
id: track.id,
detail: track,
privileges: privileges,
updateTime: new Date().getTime(),
});
}
export function getTrackDetailFromCache(ids) {
return db.trackDetail
.filter(track => {
return ids.includes(String(track.id));
})
.toArray()
.then(tracks => {
const result = { songs: [], privileges: [] };
ids.map(id => {
const one = tracks.find(t => String(t.id) === id);
result.songs.push(one?.detail);
result.privileges.push(one?.privileges);
});
if (result.songs.includes(undefined)) {
return undefined;
}
return result;
});
}
export function cacheLyric(id, lyrics) {
db.lyric.put({
id,
lyrics,
updateTime: new Date().getTime(),
});
}
export function getLyricFromCache(id) {
return db.lyric.get(Number(id)).then(result => {
if (!result) return undefined;
return result.lyrics;
});
}
export function cacheAlbum(id, album) {
db.album.put({
id: Number(id),
album,
updateTime: new Date().getTime(),
});
}
export function getAlbumFromCache(id) {
return db.album.get(Number(id)).then(result => {
if (!result) return undefined;
return result.album;
});
}
export function countDBSize() {
const trackSizes = [];
return db.trackSources
.each(track => {
trackSizes.push(track.source.byteLength);
})
.then(() => {
const res = {
bytes: trackSizes.reduce((s1, s2) => s1 + s2, 0),
length: trackSizes.length,
};
tracksCacheBytes = res.bytes;
console.debug(
`[debug][db.js] load tracksCacheBytes: ${tracksCacheBytes}`
);
return res;
});
}
export function clearDB() {
return new Promise(resolve => {
db.tables.forEach(function (table) {
table.clear();
});
resolve();
});
}
================================================
FILE: src/utils/filters.js
================================================
import Vue from 'vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import locale from '@/locale';
Vue.filter('formatTime', (Milliseconds, format = 'HH:MM:SS') => {
if (!Milliseconds) return '';
dayjs.extend(duration);
dayjs.extend(relativeTime);
let time = dayjs.duration(Milliseconds);
let hours = time.hours().toString();
let mins = time.minutes().toString();
let seconds = time.seconds().toString().padStart(2, '0');
if (format === 'HH:MM:SS') {
return hours !== '0'
? `${hours}:${mins.padStart(2, '0')}:${seconds}`
: `${mins}:${seconds}`;
} else if (format === 'Human') {
let hoursUnit, minitesUnit;
switch (locale.locale) {
case 'zh-CN':
hoursUnit = '小时';
minitesUnit = '分钟';
break;
case 'zh-TW':
hoursUnit = '小時';
minitesUnit = '分鐘';
break;
default:
hoursUnit = 'hr';
minitesUnit = 'min';
break;
}
return hours !== '0'
? `${hours} ${hoursUnit} ${mins} ${minitesUnit}`
: `${mins} ${minitesUnit}`;
}
});
Vue.filter('formatDate', (timestamp, format = 'MMM D, YYYY') => {
if (!timestamp) return '';
if (locale.locale === 'zh-CN') format = 'YYYY年MM月DD日';
else if (locale.locale === 'zh-TW') format = 'YYYY年MM月DD日';
return dayjs(timestamp).format(format);
});
Vue.filter('formatAlbumType', (type, album) => {
if (!type) return '';
if (type === 'EP/Single') {
return album.size === 1 ? 'Single' : 'EP';
} else if (type === 'Single') {
return 'Single';
} else if (type === '专辑') {
return 'Album';
} else {
return type;
}
});
Vue.filter('resizeImage', (imgUrl, size = 512) => {
if (!imgUrl) return '';
let httpsImgUrl = imgUrl;
if (imgUrl.slice(0, 5) !== 'https') {
httpsImgUrl = 'https' + imgUrl.slice(4);
}
return `${httpsImgUrl}?param=${size}y${size}`;
});
Vue.filter('formatPlayCount', count => {
if (!count) return '';
if (locale.locale === 'zh-CN') {
if (count > 100000000) {
return `${Math.floor((count / 100000000) * 100) / 100}亿`; // 2.32 亿
}
if (count > 100000) {
return `${Math.floor((count / 10000) * 10) / 10}万`; // 232.1 万
}
if (count > 10000) {
return `${Math.floor((count / 10000) * 100) / 100}万`; // 2.3 万
}
return count;
} else if (locale.locale === 'zh-TW') {
if (count > 100000000) {
return `${Math.floor((count / 100000000) * 100) / 100}億`; // 2.32 億
}
if (count > 100000) {
return `${Math.floor((count / 10000) * 10) / 10}萬`; // 232.1 萬
}
if (count > 10000) {
return `${Math.floor((count / 10000) * 100) / 100}萬`; // 2.3 萬
}
return count;
} else {
if (count > 10000000) {
return `${Math.floor((count / 1000000) * 10) / 10}M`; // 233.2M
}
if (count > 1000000) {
return `${Math.floor((count / 1000000) * 100) / 100}M`; // 2.3M
}
if (count > 1000) {
return `${Math.floor((count / 1000) * 100) / 100}K`; // 233.23K
}
return count;
}
});
Vue.filter('toHttps', url => {
if (!url) return '';
return url.replace(/^http:/, 'https:');
});
================================================
FILE: src/utils/lyrics.js
================================================
export function lyricParser(lrc) {
return {
lyric: parseLyric(lrc?.lrc?.lyric || ''),
tlyric: parseLyric(lrc?.tlyric?.lyric || ''),
romalyric: parseLyric(lrc?.romalrc?.lyric || ''),
lyricuser: lrc.lyricUser,
transuser: lrc.transUser,
};
}
// regexr.com/6e52n
const extractLrcRegex =
/^(?(?:\[.+?\])+)(?!\[)(?.+)$/gm;
const extractTimestampRegex =
/\[(?\d+):(?\d+)(?:\.|:)*(?\d+)*\]/g;
/**
* @typedef {{time: number, rawTime: string, content: string}} ParsedLyric
*/
/**
* Parse the lyric string.
*
* @param {string} lrc The `lrc` input.
* @returns {ParsedLyric[]} The parsed lyric.
* @example parseLyric("[00:00.00] Hello, World!\n[00:00.10] Test\n");
*/
function parseLyric(lrc) {
/**
* A sorted list of parsed lyric and its timestamp.
*
* @type {ParsedLyric[]}
* @see binarySearch
*/
const parsedLyrics = [];
/**
* Find the appropriate index to push our parsed lyric.
* @param {ParsedLyric} lyric
*/
const binarySearch = lyric => {
let time = lyric.time;
let low = 0;
let high = parsedLyrics.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midTime = parsedLyrics[mid].time;
if (midTime === time) {
return mid;
} else if (midTime < time) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
};
for (const line of lrc.trim().matchAll(extractLrcRegex)) {
const { lyricTimestamps, content } = line.groups;
for (const timestamp of lyricTimestamps.matchAll(extractTimestampRegex)) {
const { min, sec, ms } = timestamp.groups;
const validMs = ms?.slice(0, 2) ?? '00';
const rawTime = `[${min}:${sec}.${validMs}]`;
const time = Number(min) * 60 + Number(sec) + Number(validMs) * 0.01;
/** @type {ParsedLyric} */
const parsedLyric = { rawTime, time, content: trimContent(content) };
parsedLyrics.splice(binarySearch(parsedLyric), 0, parsedLyric);
}
}
return parsedLyrics;
}
/**
* @param {string} content
* @returns {string}
*/
function trimContent(content) {
let t = content.trim();
return t.length < 1 ? content : t;
}
/**
* @param {string} lyric
*/
export async function copyLyric(lyric) {
const textToCopy = lyric;
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(textToCopy);
} catch (err) {
alert('复制失败,请手动复制!');
}
} else {
const tempInput = document.createElement('textarea');
tempInput.value = textToCopy;
tempInput.style.position = 'absolute';
tempInput.style.left = '-9999px';
document.body.appendChild(tempInput);
tempInput.select();
try {
document.execCommand('copy');
} catch (err) {
alert('复制失败,请手动复制!');
}
document.body.removeChild(tempInput);
}
}
================================================
FILE: src/utils/nativeAlert.js
================================================
/**
* Returns an alert-like function that fits current runtime environment
*
* This function is amid to solve a electron bug on Windows, that, when
* user dismissed a browser alert, elements cannot be focused
* for further editing unless switching to another window and then back
*
* @returns { (message:string) => void }
* Built-in alert function for browser environment
* A function wrapping {@link dialog.showMessageBoxSync} for electron environment
*
* @see {@link https://github.com/electron/electron/issues/19977} for upstream electron issue
*/
const nativeAlert = (() => {
if (process.env.IS_ELECTRON === true) {
const { dialog } = require('electron');
if (dialog) {
return message => {
var options = {
type: 'warning',
message,
};
dialog.showMessageBoxSync(null, options);
};
}
}
return alert;
})();
export default nativeAlert;
================================================
FILE: src/utils/platform.js
================================================
export const isWindows = process.platform === 'win32';
export const isMac = process.platform === 'darwin';
export const isLinux = process.platform === 'linux';
export const isDevelopment = process.env.NODE_ENV === 'development';
export const isCreateTray = isWindows || isLinux || isDevelopment;
export const isCreateMpris = isLinux;
================================================
FILE: src/utils/playList.js
================================================
import router from '../router';
import state from '../store/state';
import {
recommendPlaylist,
dailyRecommendPlaylist,
getPlaylistDetail,
} from '@/api/playlist';
import { isAccountLoggedIn } from '@/utils/auth';
export function hasListSource() {
return !state.player.isPersonalFM && state.player.playlistSource.id !== 0;
}
export function goToListSource() {
router.push({ path: getListSourcePath() });
}
export function getListSourcePath() {
if (state.player.playlistSource.id === state.data.likedSongPlaylistID) {
return '/library/liked-songs';
} else if (state.player.playlistSource.type === 'url') {
return state.player.playlistSource.id;
} else if (state.player.playlistSource.type === 'cloudDisk') {
return '/library';
} else {
return `/${state.player.playlistSource.type}/${state.player.playlistSource.id}`;
}
}
export async function getRecommendPlayList(limit, removePrivateRecommand) {
if (isAccountLoggedIn()) {
const playlists = await Promise.all([
dailyRecommendPlaylist(),
recommendPlaylist({ limit }),
]);
let recommend = playlists[0].recommend ?? [];
if (recommend.length) {
if (removePrivateRecommand) recommend = recommend.slice(1);
await replaceRecommendResult(recommend);
}
return recommend.concat(playlists[1].result).slice(0, limit);
} else {
const response = await recommendPlaylist({ limit });
return response.result;
}
}
async function replaceRecommendResult(recommend) {
for (let r of recommend) {
if (specialPlaylist.indexOf(r.id) > -1) {
const data = await getPlaylistDetail(r.id, true);
const playlist = data.playlist;
if (playlist) {
r.name = playlist.name;
r.picUrl = playlist.coverImgUrl;
}
}
}
}
const specialPlaylist = [3136952023, 2829883282, 2829816518, 2829896389];
================================================
FILE: src/utils/request.js
================================================
import router from '@/router';
import { doLogout, getCookie } from '@/utils/auth';
import axios from 'axios';
let baseURL = '';
// Web 和 Electron 跑在不同端口避免同时启动时冲突
if (process.env.IS_ELECTRON) {
if (process.env.NODE_ENV === 'production') {
baseURL = process.env.VUE_APP_ELECTRON_API_URL;
} else {
baseURL = process.env.VUE_APP_ELECTRON_API_URL_DEV;
}
} else {
baseURL = process.env.VUE_APP_NETEASE_API_URL;
}
const service = axios.create({
baseURL,
withCredentials: true,
timeout: 15000,
});
service.interceptors.request.use(function (config) {
if (!config.params) config.params = {};
if (baseURL.length) {
if (
baseURL[0] !== '/' &&
!process.env.IS_ELECTRON &&
getCookie('MUSIC_U') !== null
) {
config.params.cookie = `MUSIC_U=${getCookie('MUSIC_U')};`;
}
} else {
console.error("You must set up the baseURL in the service's config");
}
if (!process.env.IS_ELECTRON && !config.url.includes('/login')) {
config.params.realIP = '211.161.244.70';
}
// Force real_ip
const enableRealIP = JSON.parse(
localStorage.getItem('settings')
).enableRealIP;
const realIP = JSON.parse(localStorage.getItem('settings')).realIP;
if (process.env.VUE_APP_REAL_IP) {
config.params.realIP = process.env.VUE_APP_REAL_IP;
} else if (enableRealIP) {
config.params.realIP = realIP;
}
const proxy = JSON.parse(localStorage.getItem('settings')).proxyConfig;
if (['HTTP', 'HTTPS'].includes(proxy.protocol)) {
config.params.proxy = `${proxy.protocol}://${proxy.server}:${proxy.port}`;
}
return config;
});
service.interceptors.response.use(
response => {
const res = response.data;
return res;
},
async error => {
/** @type {import('axios').AxiosResponse | null} */
let response;
let data;
if (error === 'TypeError: baseURL is undefined') {
response = error;
data = error;
console.error("You must set up the baseURL in the service's config");
} else if (error.response) {
response = error.response;
data = response.data;
}
if (
response &&
typeof data === 'object' &&
data.code === 301 &&
data.msg === '需要登录'
) {
console.warn('Token has expired. Logout now!');
// 登出帳戶
doLogout();
// 導向登入頁面
if (process.env.IS_ELECTRON === true) {
router.push({ name: 'loginAccount' });
} else {
router.push({ name: 'login' });
}
}
}
);
export default service;
================================================
FILE: src/utils/shortcuts.js
================================================
// default shortcuts
// for more info, check https://www.electronjs.org/docs/api/accelerator
export default [
{
id: 'play',
name: '播放/暂停',
shortcut: 'CommandOrControl+P',
globalShortcut: 'Alt+CommandOrControl+P',
},
{
id: 'next',
name: '下一首',
shortcut: 'CommandOrControl+Right',
globalShortcut: 'Alt+CommandOrControl+Right',
},
{
id: 'previous',
name: '上一首',
shortcut: 'CommandOrControl+Left',
globalShortcut: 'Alt+CommandOrControl+Left',
},
{
id: 'increaseVolume',
name: '增加音量',
shortcut: 'CommandOrControl+Up',
globalShortcut: 'Alt+CommandOrControl+Up',
},
{
id: 'decreaseVolume',
name: '减少音量',
shortcut: 'CommandOrControl+Down',
globalShortcut: 'Alt+CommandOrControl+Down',
},
{
id: 'like',
name: '喜欢歌曲',
shortcut: 'CommandOrControl+L',
globalShortcut: 'Alt+CommandOrControl+L',
},
{
id: 'minimize',
name: '隐藏/显示播放器',
shortcut: 'CommandOrControl+M',
globalShortcut: 'Alt+CommandOrControl+M',
},
];
================================================
FILE: src/utils/staticData.js
================================================
export const byAppleMusic = [
{
coverImgUrl:
'https://p2.music.126.net/GvYQoflE99eoeGi9jG4Bsw==/109951165375336156.jpg',
name: 'Happy Hits',
id: 5278068783,
},
{
coverImgUrl:
'https://p2.music.126.net/5CJeYN35LnzRDsv5Lcs0-Q==/109951165374966765.jpg',
name: '\u4e2d\u563b\u5408\u74a7',
id: 5277771961,
},
{
coverImgUrl:
'https://p1.music.126.net/cPaBXr1wZSg86ddl47AK7Q==/109951165375130918.jpg',
name: 'Heartbreak Pop',
id: 5277965913,
},
{
coverImgUrl:
'https://p2.music.126.net/FDtX55P2NjccDna-LBj9PA==/109951165375065973.jpg',
name: 'Festival Bangers',
id: 5277969451,
},
{
coverImgUrl:
'https://p2.music.126.net/hC0q2dGbOWHVfg4nkhIXPg==/109951165374881177.jpg',
name: 'Bedtime Beats',
id: 5277778542,
},
];
export const playlistCategories = [
{
name: '全部',
enable: true,
bigCat: 'static',
},
// {
// name: "For You",
// enable: true,
// bigCat: "static",
// },
{
name: '推荐歌单',
enable: true,
bigCat: 'static',
},
// {
// name: "最新专辑",
// enable: false,
// bigCat: "static",
// },
{
name: '精品歌单',
enable: true,
bigCat: 'static',
},
{
name: '官方',
enable: true,
bigCat: 'static',
},
{
name: '排行榜',
enable: true,
bigCat: 'static',
},
{
name: '华语',
enable: false,
bigCat: '语种',
},
{
name: '欧美',
enable: true,
bigCat: '语种',
},
{
name: '日语',
enable: false,
bigCat: '语种',
},
{
name: '韩语',
enable: false,
bigCat: '语种',
},
{
name: '粤语',
enable: false,
bigCat: '语种',
},
{
name: '流行',
enable: true,
bigCat: '风格',
},
{
name: '摇滚',
enable: true,
bigCat: '风格',
},
{
name: '民谣',
enable: false,
bigCat: '风格',
},
{
name: '电子',
enable: true,
bigCat: '风格',
},
{
name: '舞曲',
enable: false,
bigCat: '风格',
},
{
name: '说唱',
enable: true,
bigCat: '风格',
},
{
name: '轻音乐',
enable: false,
bigCat: '风格',
},
{
name: '爵士',
enable: false,
bigCat: '风格',
},
{
name: '乡村',
enable: false,
bigCat: '风格',
},
{
name: 'R&B/Soul',
enable: false,
bigCat: '风格',
},
{
name: '古典',
enable: false,
bigCat: '风格',
},
{
name: '民族',
enable: false,
bigCat: '风格',
},
{
name: '英伦',
enable: false,
bigCat: '风格',
},
{
name: '金属',
enable: false,
bigCat: '风格',
},
{
name: '朋克',
enable: false,
bigCat: '风格',
},
{
name: '蓝调',
enable: false,
bigCat: '风格',
},
{
name: '雷鬼',
enable: false,
bigCat: '风格',
},
{
name: '世界音乐',
enable: false,
bigCat: '风格',
},
{
name: '拉丁',
enable: false,
bigCat: '风格',
},
{
name: 'New Age',
enable: false,
bigCat: '风格',
},
{
name: '古风',
enable: false,
bigCat: '风格',
},
{
name: '后摇',
enable: false,
bigCat: '风格',
},
{
name: 'Bossa Nova',
enable: false,
bigCat: '风格',
},
{
name: '清晨',
enable: false,
bigCat: '场景',
},
{
name: '夜晚',
enable: false,
bigCat: '场景',
},
{
name: '学习',
enable: false,
bigCat: '场景',
},
{
name: '工作',
enable: false,
bigCat: '场景',
},
{
name: '午休',
enable: false,
bigCat: '场景',
},
{
name: '下午茶',
enable: false,
bigCat: '场景',
},
{
name: '地铁',
enable: false,
bigCat: '场景',
},
{
name: '驾车',
enable: false,
bigCat: '场景',
},
{
name: '运动',
enable: false,
bigCat: '场景',
},
{
name: '旅行',
enable: false,
bigCat: '场景',
},
{
name: '散步',
enable: false,
bigCat: '场景',
},
{
name: '酒吧',
enable: false,
bigCat: '场景',
},
{
name: '怀旧',
enable: false,
bigCat: '情感',
},
{
name: '清新',
enable: false,
bigCat: '情感',
},
{
name: '浪漫',
enable: false,
bigCat: '情感',
},
{
name: '伤感',
enable: false,
bigCat: '情感',
},
{
name: '治愈',
enable: false,
bigCat: '情感',
},
{
name: '放松',
enable: false,
bigCat: '情感',
},
{
name: '孤独',
enable: false,
bigCat: '情感',
},
{
name: '感动',
enable: false,
bigCat: '情感',
},
{
name: '兴奋',
enable: false,
bigCat: '情感',
},
{
name: '快乐',
enable: false,
bigCat: '情感',
},
{
name: '安静',
enable: false,
bigCat: '情感',
},
{
name: '思念',
enable: false,
bigCat: '情感',
},
{
name: '综艺',
enable: false,
bigCat: '主题',
},
{
name: '影视原声',
enable: false,
bigCat: '主题',
},
{
name: 'ACG',
enable: true,
bigCat: '主题',
},
{
name: '儿童',
enable: false,
bigCat: '主题',
},
{
name: '校园',
enable: false,
bigCat: '主题',
},
{
name: '游戏',
enable: false,
bigCat: '主题',
},
{
name: '70后',
enable: false,
bigCat: '主题',
},
{
name: '80后',
enable: false,
bigCat: '主题',
},
{
name: '90后',
enable: false,
bigCat: '主题',
},
{
name: '网络歌曲',
enable: false,
bigCat: '主题',
},
{
name: 'KTV',
enable: false,
bigCat: '主题',
},
{
name: '经典',
enable: false,
bigCat: '主题',
},
{
name: '翻唱',
enable: false,
bigCat: '主题',
},
{
name: '吉他',
enable: false,
bigCat: '主题',
},
{
name: '钢琴',
enable: false,
bigCat: '主题',
},
{
name: '器乐',
enable: false,
bigCat: '主题',
},
{
name: '榜单',
enable: false,
bigCat: '主题',
},
{
name: '00后',
enable: false,
bigCat: '主题',
},
];
================================================
FILE: src/utils/updateApp.js
================================================
import initLocalStorage from '@/store/initLocalStorage.js';
import pkg from '../../package.json';
const updateSetting = () => {
const parsedSettings = JSON.parse(localStorage.getItem('settings'));
const settings = {
...initLocalStorage.settings,
...parsedSettings,
};
if (
settings.shortcuts.length !== initLocalStorage.settings.shortcuts.length
) {
// 当新增 shortcuts 时
const oldShortcutsId = settings.shortcuts.map(s => s.id);
const newShortcutsId = initLocalStorage.settings.shortcuts.filter(
s => oldShortcutsId.includes(s.id) === false
);
newShortcutsId.map(id => {
settings.shortcuts.push(
initLocalStorage.settings.shortcuts.find(s => s.id === id)
);
});
}
if (localStorage.getItem('appVersion') === '"0.3.9"') {
settings.lyricsBackground = true;
}
localStorage.setItem('settings', JSON.stringify(settings));
};
const updateData = () => {
const parsedData = JSON.parse(localStorage.getItem('data'));
const data = {
...parsedData,
};
localStorage.setItem('data', JSON.stringify(data));
};
const updatePlayer = () => {
let parsedData = JSON.parse(localStorage.getItem('player'));
let appVersion = localStorage.getItem('appVersion');
if (appVersion === `"0.2.5"`) parsedData = {}; // 0.2.6版本重构了player
const data = {
...parsedData,
};
localStorage.setItem('player', JSON.stringify(data));
};
const removeOldStuff = () => {
// remove old indexedDB databases created by localforage
indexedDB.deleteDatabase('tracks');
};
export default function () {
updateSetting();
updateData();
updatePlayer();
removeOldStuff();
localStorage.setItem('appVersion', JSON.stringify(pkg.version));
}
================================================
FILE: src/views/album.vue
================================================
{{ title }}
{{
subtitle
}}
{{ album.type | formatAlbumType(album) }} by {{
album.artist.name
}}
Compilation by Various Artists
{{
new Date(album.publishTime).getFullYear()
}}
· {{ album.size }} {{ $t('common.songs') }},
{{ albumTime | formatTime('Human') }}
{{ album.description }}
{{ $t('common.play') }}
More by
{{ album.artist.name }}
{{ album.description }}
{{
dynamicDetail.isSub
? $t('contextMenu.removeFromLibrary')
: $t('contextMenu.saveToLibrary')
}}
{{ $t('contextMenu.addToPlaylist') }}
{{
$t('contextMenu.copyUrl')
}}
{{
$t('contextMenu.openInBrowser')
}}
================================================
FILE: src/views/artist.vue
================================================
{{ artist.name }}
{{ $t('artist.artist') }}
{{ artist.briefDesc }}
{{ $t('common.play') }}
{{ $t('artist.following') }}
{{ $t('artist.follow') }}
{{ $t('artist.latestRelease') }}
{{
latestRelease.name
}}
{{ latestRelease.publishTime | formatDate }}
{{ latestRelease.type | formatAlbumType(latestRelease) }} ·
{{ latestRelease.size }} {{ $t('common.songs') }}
{{
latestMV.name
}}
{{ latestMV.publishTime | formatDate }}
{{ $t('artist.latestMV') }}
{{ $t('artist.popularSongs') }}
{{ $t('artist.albums') }}
MVs
{{
$t('home.seeMore')
}}
{{ $t('artist.EPsSingles') }}
{{ $t('artist.similarArtists') }}
{{ artist.briefDesc }}
{{
$t('contextMenu.copyUrl')
}}
{{
$t('contextMenu.openInBrowser')
}}
================================================
FILE: src/views/artistMV.vue
================================================
{{ artist.name }}'s Music Videos
{{
$t('explore.loadMore')
}}
================================================
FILE: src/views/dailyTracks.vue
================================================
每日歌曲推荐
根据你的音乐口味生成 · 每天6:00更新
================================================
FILE: src/views/explore.vue
================================================
{{ $t('explore.explore') }}
{{ $t('explore.loadMore') }}
================================================
FILE: src/views/home.vue
================================================
{{ $t('home.recommendPlaylist') }}
{{
$t('home.seeMore')
}}
{{ $t('home.recommendArtist') }}
{{ $t('home.newAlbum') }}
{{ $t('home.seeMore') }}
{{ $t('home.charts') }}
{{
$t('home.seeMore')
}}
================================================
FILE: src/views/lastfmCallback.vue
================================================
================================================
FILE: src/views/library.vue
================================================
{{ data.user.nickname }}{{ $t('library.sLibrary') }}
{{ $t('library.likedSongs') }}
{{ liked.songs.length }} {{ $t('common.songs') }}
{{
{
all: $t('contextMenu.allPlaylists'),
mine: $t('contextMenu.minePlaylists'),
liked: $t('contextMenu.likedPlaylists'),
}[playlistFilter]
}}
{{ $t('library.albums') }}
{{ $t('library.artists') }}
{{ $t('library.mvs') }}
{{ $t('library.cloudDisk') }}
{{ $t('library.playHistory.title') }}
{{
$t('contextMenu.allPlaylists')
}}
{{
$t('contextMenu.minePlaylists')
}}
{{
$t('contextMenu.likedPlaylists')
}}
{{
$t('library.likedSongs')
}}
{{
$t('contextMenu.cardiacMode')
}}
================================================
FILE: src/views/login.vue
================================================
{{ $t('login.loginText') }}
{{ $t('login.accessToAll') }}
{{ $t('login.search') }}
{{ $t('login.readonly') }}
================================================
FILE: src/views/loginAccount.vue
================================================
{{ $t('login.loginText') }}
================================================
FILE: src/views/loginUsername.vue
================================================
{{ $t('login.usernameLogin') }}
{{ $t('login.enterTip') }}
{{ $t('login.choose') }}
{{ user.nickname }}
{{ $t('login.confirm') }}
================================================
FILE: src/views/lyrics.vue
================================================
{{ date }}
{{ currentTrack.name }}
{{ currentTrack.name }}
{{ artist.name }}
{{ artist.name }}
-
{{ album.name }}
{{ formatTrackTime(player.progress) || '0:00' }}
{{ formatTrackTime(player.currentTrackDuration) }}
{{ line.contents[0] }}
{{ line.contents[1] }}
{{
$t('contextMenu.copyLyric')
}}
{{ $t('contextMenu.copyLyricWithTranslation') }}
================================================
FILE: src/views/mv.vue
================================================
{{
mv.data.artistName
}}
-
{{ mv.data.name }}
{{ mv.data.playCount | formatPlayCount }} Views ·
{{ mv.data.publishTime }}
{{
$t('contextMenu.copyUrl')
}}
{{
$t('contextMenu.openInBrowser')
}}
================================================
FILE: src/views/newAlbum.vue
================================================
{{ $t('home.newAlbum') }}
================================================
FILE: src/views/next.vue
================================================
{{ $t('next.nowPlaying') }}
插队播放
{{ $t('next.nextUp') }}
================================================
FILE: src/views/playlist.vue
================================================
{{ playlist.name }}
{{ $t('playlist.updatedAt') }}
{{ playlist.updateTime | formatDate }} · {{ playlist.trackCount }}
{{ $t('common.songs') }}
{{ playlist.description }}
{{ $t('common.play') }}
{{ specialPlaylistInfo.name }}
{{ playlist.englishTitle }} · {{ playlist.updateFrequency }}
{{ $t('common.play') }}
{{ data.user.nickname }}{{ $t('library.sLikedSongs') }}
{{ $t('explore.loadMore') }}
{{ playlist.description }}
{{
playlist.subscribed
? $t('contextMenu.removeFromLibrary')
: $t('contextMenu.saveToLibrary')
}}
{{
$t('contextMenu.searchInPlaylist')
}}
编辑歌单信息
删除歌单
================================================
FILE: src/views/search.vue
================================================
{{ $t('search.artist')
}}{{
$t('home.seeMore')
}}
{{ $t('search.album')
}}{{
$t('home.seeMore')
}}
{{ $t('search.song')
}}{{
$t('home.seeMore')
}}
{{ $t('search.mv')
}}{{
$t('home.seeMore')
}}
{{ $t('search.playlist')
}}{{
$t('home.seeMore')
}}
{{
keywords.length === 0 ? '输入关键字搜索' : $t('search.noResult')
}}
================================================
FILE: src/views/searchType.vue
================================================
{{ $t('search.searchFor') }} {{ typeNameTable[type] }} "{{
keywords
}}"
{{
$t('explore.loadMore')
}}
================================================
FILE: src/views/settings.vue
================================================
{{ $t('settings.language') }}
{{ $t('settings.appearance.text') }}
{{ $t('settings.trayIcon.text') }}
{{ $t('settings.MusicGenrePreference.text') }}
{{ $t('settings.musicQuality.text') }}
{{ $t('settings.deviceSelector') }}
缓存
{{ $t('settings.automaticallyCacheSongs') }}
{{ $t('settings.cacheLimit.text') }}
{{
$t('settings.cacheCount', {
song: tracksCache.length,
size: tracksCache.size,
})
}}
{{ $t('settings.lyric') }}
{{ $t('settings.showLyricsTranslation') }}
{{ $t('settings.lyricsBackground.text') }}
{{ $t('settings.showLyricsTime') }}
{{ $t('settings.lyricFontSize.text') }}
{{ $t('settings.unm.enable') }}
OSDLyrics
{{ $t('settings.enableOsdlyricsSupport.title') }}
{{ $t('settings.enableOsdlyricsSupport.desc1') }}
{{ $t('settings.enableOsdlyricsSupport.desc2') }}
UnblockNeteaseMusic
{{ $t('settings.unm.enableFlac.title') }}
{{ $t('settings.unm.enableFlac.desc') }}
{{ $t('settings.unm.searchMode.title') }}
{{ $t('settings.unm.proxy.title') }}
{{ $t('settings.unm.proxy.desc1') }}
{{ $t('settings.unm.proxy.desc2') }}
{{ $t('settings.customization') }}
{{
isLastfmConnected
? `已连接到 Last.fm (${lastfm.name})`
: '连接 Last.fm '
}}
{{ $t('settings.enableDiscordRichPresence') }}
{{ $t('settings.others') }}
{{ $t('settings.closeAppOption.text') }}
{{ $t('settings.enableCustomTitlebar') }}
{{ $t('settings.showLibraryDefault') }}
{{ $t('settings.showPlaylistsByAppleMusic') }}
{{ $t('settings.subTitleDefault') }}
{{ $t('settings.enableReversedMode') }}
快捷键
{{ $t('settings.enableGlobalShortcut') }}
{{ shortcut.name }}
{{
shortcutInput.id === shortcut.id &&
shortcutInput.type === 'shortcut' &&
recordedShortcutComputed !== ''
? formatShortcut(recordedShortcutComputed)
: formatShortcut(shortcut.shortcut)
}}
{{
shortcutInput.id === shortcut.id &&
shortcutInput.type === 'globalShortcut' &&
recordedShortcutComputed !== ''
? formatShortcut(recordedShortcutComputed)
: formatShortcut(shortcut.globalShortcut)
}}
================================================
FILE: vercel.example.json
================================================
{
"rewrites": [
{
"source": "/api/:match*",
"destination": "https://your-netease-api.example.com/:match*"
}
]
}
================================================
FILE: vue.config.js
================================================
const webpack = require('webpack');
const path = require('path');
function resolve(dir) {
return path.join(__dirname, dir);
}
module.exports = {
// 生产环境打包不输出 map
productionSourceMap: false,
devServer: {
disableHostCheck: true,
port: process.env.DEV_SERVER_PORT || 8080,
proxy: {
'^/api': {
target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: {
'^/api': '/',
},
},
},
},
pwa: {
name: 'YesPlayMusic',
iconPaths: {
favicon32: 'img/icons/favicon-32x32.png',
},
themeColor: '#ffffff00',
manifestOptions: {
background_color: '#335eea',
},
// workboxOptions: {
// swSrc: "dev/sw.js",
// },
},
pages: {
index: {
entry: 'src/main.js',
template: 'public/index.html',
filename: 'index.html',
title: 'YesPlayMusic',
chunks: ['main', 'chunk-vendors', 'chunk-common', 'index'],
},
},
chainWebpack(config) {
config.module.rules.delete('svg');
config.module.rule('svg').exclude.add(resolve('src/assets/icons')).end();
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]',
})
.end();
config.module
.rule('napi')
.test(/\.node$/)
.use('node-loader')
.loader('node-loader')
.end();
config.module
.rule('webpack4_es_fallback')
.test(/\.js$/)
.include.add(/node_modules/)
.end()
.use('esbuild-loader')
.loader('esbuild-loader')
.options({ target: 'es2015', format: "cjs" })
.end();
// LimitChunkCountPlugin 可以通过合并块来对块进行后期处理。用以解决 chunk 包太多的问题
config.plugin('chunkPlugin').use(webpack.optimize.LimitChunkCountPlugin, [
{
maxChunks: 3,
minChunkSize: 10_000,
},
]);
},
// 添加插件的配置
pluginOptions: {
// electron-builder的配置文件
electronBuilder: {
nodeIntegration: true,
externals: ['@unblockneteasemusic/rust-napi'],
builderOptions: {
productName: 'YesPlayMusic',
copyright: 'Copyright © YesPlayMusic',
// compression: "maximum", // 机器好的可以打开,配置压缩,开启后会让 .AppImage 格式的客户端启动缓慢
asar: true,
publish: [
{
provider: 'github',
owner: 'qier222',
repo: 'YesPlayMusic',
vPrefixedTagName: true,
releaseType: 'draft',
},
],
directories: {
output: 'dist_electron',
},
mac: {
target: [
{
target: 'dmg',
arch: ['x64', 'arm64', 'universal'],
},
],
artifactName: '${productName}-${os}-${version}-${arch}.${ext}',
category: 'public.app-category.music',
darkModeSupport: true,
},
win: {
target: [
{
target: 'portable',
arch: ['x64'],
},
{
target: 'nsis',
arch: ['x64'],
},
],
publisherName: 'YesPlayMusic',
icon: 'build/icons/icon.ico',
publish: ['github'],
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64'],
},
{
target: 'tar.gz',
arch: ['x64', 'arm64'],
},
{
target: 'deb',
arch: ['x64', 'armv7l', 'arm64'],
},
{
target: 'rpm',
arch: ['x64'],
},
{
target: 'snap',
arch: ['x64'],
},
{
target: 'pacman',
arch: ['x64'],
},
],
category: 'Music',
icon: './build/icon.icns',
},
dmg: {
icon: 'build/icons/icon.icns',
},
nsis: {
oneClick: true,
perMachine: true,
deleteAppDataOnUninstall: true,
},
},
// 主线程的配置文件
chainWebpackMainProcess: config => {
config.plugin('define').tap(args => {
args[0]['IS_ELECTRON'] = true;
return args;
});
config.resolve.alias.set(
'jsbi',
path.join(__dirname, 'node_modules/jsbi/dist/jsbi-cjs.js')
);
config.module
.rule('webpack4_es_fallback')
.test(/\.js$/)
.include.add(/node_modules/)
.end()
.use('esbuild-loader')
.loader('esbuild-loader')
.options({ target: 'es2015', format: "cjs" })
.end();
},
// 渲染线程的配置文件
chainWebpackRendererProcess: config => {
// 渲染线程的一些其他配置
// Chain webpack config for electron renderer process only
// The following example will set IS_ELECTRON to true in your app
config.plugin('define').tap(args => {
args[0]['IS_ELECTRON'] = true;
return args;
});
},
// 主入口文件
// mainProcessFile: 'src/main.js',
// mainProcessArgs: []
},
},
};