Full Code of qier222/YesPlayMusic for AI

master 729761da7e2c cached
126 files
565.3 KB
167.4k tokens
244 symbols
1 requests
Download .txt
Showing preview only (598K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<div align="center">
	<a href="http://go.warp.dev/YesPlayMusic" target="_blank">
		<sup>Special thanks to:</sup>
		<br>
		<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png?raw=true">
		<br>
		<h>Warp is built for coding with multiple AI agents</b>
		<br>
		<sup>Available for macOS, Linux and Windows</sup>
	</a>
</div>

<br>

---

<br />
<p align="center">
  <a href="https://music.qier222.com" target="blank">
    <img src="images/logo.png" alt="Logo" width="156" height="156">
  </a>
  <h2 align="center" style="font-weight: 600">YesPlayMusic</h2>

  <p align="center">
    高颜值的第三方网易云播放器
    <br />
    <a href="https://music.qier222.com" target="blank"><strong>🌎 访问DEMO</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp;
    <a href="#%EF%B8%8F-安装" target="blank"><strong>📦️ 下载安装包</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp;
    <a href="https://t.me/yesplaymusic" target="blank"><strong>💬 加入交流群</strong></a>
    <br />
    <br />
  </p>
</p>

[![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 上的网站。

[![Powered by Vercel](https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg)](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]

<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->

[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<qier222@outlook.com>",
  "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
================================================
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="referrer" content="no-referrer" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
  <title>
    <%= htmlWebpackPlugin.options.title %>
  </title>
</head>

<body>
  <noscript>
    <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
        properly without JavaScript enabled. Please enable it to
        continue.</strong>
  </noscript>
  <div id="app"></div>
  <!-- built files will be auto injected -->
</body>

</html>


================================================
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
================================================
<template>
  <div id="app" :class="{ 'user-select-none': userSelectNone }">
    <Scrollbar v-show="!showLyrics" ref="scrollbar" />
    <Navbar v-show="showNavbar" ref="navbar" />
    <main
      ref="main"
      :style="{ overflow: enableScrolling ? 'auto' : 'hidden' }"
      @scroll="handleScroll"
    >
      <keep-alive>
        <router-view v-if="$route.meta.keepAlive"></router-view>
      </keep-alive>
      <router-view v-if="!$route.meta.keepAlive"></router-view>
    </main>
    <transition name="slide-up">
      <Player v-if="enablePlayer" v-show="showPlayer" ref="player" />
    </transition>
    <Toast />
    <ModalAddTrackToPlaylist v-if="isAccountLoggedIn" />
    <ModalNewPlaylist v-if="isAccountLoggedIn" />
    <transition v-if="enablePlayer" name="slide-up">
      <Lyrics v-show="showLyrics" />
    </transition>
  </div>
</template>

<script>
import ModalAddTrackToPlaylist from './components/ModalAddTrackToPlaylist.vue';
import ModalNewPlaylist from './components/ModalNewPlaylist.vue';
import Scrollbar from './components/Scrollbar.vue';
import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue';
import Toast from './components/Toast.vue';
import { ipcRenderer } from './electron/ipcRenderer';
import { isAccountLoggedIn, isLooseLoggedIn } from '@/utils/auth';
import Lyrics from './views/lyrics.vue';
import { mapState } from 'vuex';

export default {
  name: 'App',
  components: {
    Navbar,
    Player,
    Toast,
    ModalAddTrackToPlaylist,
    ModalNewPlaylist,
    Lyrics,
    Scrollbar,
  },
  data() {
    return {
      isElectron: process.env.IS_ELECTRON, // true || undefined
      userSelectNone: false,
    };
  },
  computed: {
    ...mapState(['showLyrics', 'settings', 'player', 'enableScrolling']),
    isAccountLoggedIn() {
      return isAccountLoggedIn();
    },
    showPlayer() {
      return (
        [
          'mv',
          'loginUsername',
          'login',
          'loginAccount',
          'lastfmCallback',
        ].includes(this.$route.name) === false
      );
    },
    enablePlayer() {
      return this.player.enabled && this.$route.name !== 'lastfmCallback';
    },
    showNavbar() {
      return this.$route.name !== 'lastfmCallback';
    },
  },
  created() {
    if (this.isElectron) ipcRenderer(this);
    window.addEventListener('keydown', this.handleKeydown);
    this.fetchData();
  },
  methods: {
    handleKeydown(e) {
      if (e.code === 'Space') {
        if (e.target.tagName === 'INPUT') return false;
        if (this.$route.name === 'mv') return false;
        e.preventDefault();
        this.player.playOrPause();
      }
    },
    fetchData() {
      if (!isLooseLoggedIn()) return;
      this.$store.dispatch('fetchLikedSongs');
      this.$store.dispatch('fetchLikedSongsWithDetails');
      this.$store.dispatch('fetchLikedPlaylist');
      if (isAccountLoggedIn()) {
        this.$store.dispatch('fetchLikedAlbums');
        this.$store.dispatch('fetchLikedArtists');
        this.$store.dispatch('fetchLikedMVs');
        this.$store.dispatch('fetchCloudDisk');
      }
    },
    handleScroll() {
      this.$refs.scrollbar.handleScroll();
    },
  },
};
</script>

<style lang="scss">
#app {
  width: 100%;
  transition: all 0.4s;
}

main {
  position: fixed;
  top: 0;
  bottom: 0;
  right: 0;
  left: 0;
  overflow: auto;
  padding: 64px 10vw 96px 10vw;
  box-sizing: border-box;
  scrollbar-width: none; // firefox
}

@media (max-width: 1336px) {
  main {
    padding: 64px 5vw 96px 5vw;
  }
}

main::-webkit-scrollbar {
  width: 0px;
}

.slide-up-enter-active,
.slide-up-leave-active {
  transition: transform 0.4s;
}
.slide-up-enter,
.slide-up-leave-to {
  transform: translateY(100%);
}
</style>


================================================
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
================================================
<template>
  <span class="artist-in-line">
    {{ computedPrefix }}
    <span v-for="(ar, index) in filteredArtists" :key="index">
      <router-link v-if="ar.id !== 0" :to="`/artist/${ar.id}`">{{
        ar.name
      }}</router-link>
      <span v-else>{{ ar.name }}</span>
      <span v-if="index !== filteredArtists.length - 1" class="separator"
        >,</span
      >
    </span>
  </span>
</template>

<script>
export default {
  name: 'ArtistInLine',
  props: {
    artists: {
      type: Array,
      required: true,
    },
    exclude: {
      type: String,
      default: '',
    },
    prefix: {
      type: String,
      default: '',
    },
  },
  computed: {
    filteredArtists() {
      return this.artists.filter(a => a.name !== this.exclude);
    },
    computedPrefix() {
      if (this.filteredArtists.length !== 0) return this.prefix;
      else return '';
    },
  },
};
</script>

<style lang="scss" scoped>
.separator {
  /* make separator distinct enough in long list */
  margin-left: 1px;
  margin-right: 4px;
  position: relative;
  top: 0.5px;
}
</style>


================================================
FILE: src/components/ButtonIcon.vue
================================================
<template>
  <button class="button-icon"><slot></slot></button>
</template>

<script>
export default {
  name: 'ButtonIcon',
};
</script>

<style lang="scss" scoped>
button {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 8px;
  background: transparent;
  margin: 4px;
  border-radius: 25%;
  transition: 0.2s;
  .svg-icon {
    color: var(--color-text);
    height: 16px;
    width: 16px;
  }
  &:first-child {
    margin-left: 0;
  }
  &:hover {
    background: var(--color-secondary-bg-for-transparent);
  }
  &:active {
    transform: scale(0.92);
  }
}
</style>


================================================
FILE: src/components/ButtonTwoTone.vue
================================================
<template>
  <button :style="buttonStyle" :class="color">
    <svg-icon
      v-if="iconClass !== null"
      :icon-class="iconClass"
      :style="{ marginRight: iconButton ? '0px' : '8px' }"
    />
    <slot></slot>
  </button>
</template>

<script>
export default {
  name: 'ButtonTwoTone',
  props: {
    iconClass: {
      type: String,
      default: null,
    },
    iconButton: {
      type: Boolean,
      default: false,
    },
    horizontalPadding: {
      type: Number,
      default: 16,
    },
    color: {
      type: String,
      default: 'blue',
    },
    backgroundColor: {
      type: String,
      default: '',
    },
    textColor: {
      type: String,
      default: '',
    },
    shape: {
      type: String,
      default: 'square',
    },
  },
  computed: {
    buttonStyle() {
      let styles = {
        borderRadius: this.shape === 'round' ? '50%' : '8px',
        padding: `8px ${this.horizontalPadding}px`,
        // height: "38px",
        width: this.shape === 'round' ? '38px' : 'auto',
      };
      if (this.backgroundColor !== '')
        styles.backgroundColor = this.backgroundColor;
      if (this.textColor !== '') styles.color = this.textColor;
      return styles;
    },
  },
};
</script>

<style lang="scss" scoped>
button {
  height: 40px;
  min-width: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  line-height: 18px;
  font-weight: 600;
  background-color: var(--color-primary-bg);
  color: var(--color-primary);
  margin-right: 12px;
  transition: 0.2s;
  user-select: none;
  .svg-icon {
    width: 16px;
    height: 16px;
  }
  &:hover {
    transform: scale(1.06);
  }
  &:active {
    transform: scale(0.94);
  }
}
button.grey {
  background-color: var(--color-secondary-bg);
  color: var(--color-text);
  opacity: 0.78;
}
button.transparent {
  background-color: transparent;
}
</style>


================================================
FILE: src/components/ContextMenu.vue
================================================
<template>
  <div ref="contextMenu" class="context-menu">
    <div
      v-if="showMenu"
      ref="menu"
      class="menu"
      tabindex="-1"
      :style="{ top: top, left: left }"
      @blur="closeMenu"
      @click="closeMenu"
    >
      <slot></slot>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex';

export default {
  name: 'ContextMenu',
  data() {
    return {
      showMenu: false,
      top: '0px',
      left: '0px',
    };
  },
  computed: {
    ...mapState(['player']),
  },
  methods: {
    setMenu(top, left) {
      let heightOffset = this.player.enabled ? 64 : 0;
      let largestHeight =
        window.innerHeight - this.$refs.menu.offsetHeight - heightOffset;
      let largestWidth = window.innerWidth - this.$refs.menu.offsetWidth - 25;
      if (top > largestHeight) top = largestHeight;
      if (left > largestWidth) left = largestWidth;
      this.top = top + 'px';
      this.left = left + 'px';
    },

    closeMenu() {
      this.showMenu = false;
      if (this.$parent.closeMenu !== undefined) {
        this.$parent.closeMenu();
      }
      this.$store.commit('enableScrolling', true);
    },

    openMenu(e) {
      this.showMenu = true;
      this.$nextTick(
        function () {
          this.$refs.menu.focus();
          this.setMenu(e.y, e.x);
        }.bind(this)
      );
      e.preventDefault();
      this.$store.commit('enableScrolling', false);
    },
  },
};
</script>

<style lang="scss" scoped>
.context-menu {
  width: 100%;
  height: 100%;
  user-select: none;
}

.menu {
  position: fixed;
  min-width: 136px;
  max-width: 240px;
  list-style: none;
  background: rgba(255, 255, 255, 0.88);
  box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.08);
  border: 1px solid rgba(0, 0, 0, 0.06);
  backdrop-filter: blur(12px);
  border-radius: 12px;
  box-sizing: border-box;
  padding: 6px;
  z-index: 1000;
  -webkit-app-region: no-drag;
  transition: background 125ms ease-out, opacity 125ms ease-out,
    transform 125ms ease-out;

  &:focus {
    outline: none;
  }
}

[data-theme='dark'] {
  .menu {
    background: rgba(36, 36, 36, 0.78);
    backdrop-filter: blur(16px) contrast(120%) brightness(60%);
    border: 1px solid rgba(255, 255, 255, 0.08);
    box-shadow: 0 0 6px rgba(255, 255, 255, 0.08);
  }
  .menu .item:hover {
    color: var(--color-text);
  }
}

@supports (-moz-appearance: none) {
  .menu {
    background-color: var(--color-body-bg) !important;
  }
}

.menu .item {
  font-weight: 600;
  font-size: 14px;
  padding: 10px 14px;
  border-radius: 8px;
  cursor: default;
  color: var(--color-text);
  display: flex;
  align-items: center;
  &:hover {
    color: var(--color-primary);
    background: var(--color-primary-bg-for-transparent);
    transition: opacity 125ms ease-out, transform 125ms ease-out;
  }
  &:active {
    opacity: 0.75;
    transform: scale(0.95);
  }

  .svg-icon {
    height: 16px;
    width: 16px;
    margin-right: 5px;
  }
}

hr {
  margin: 4px 10px;
  background: rgba(128, 128, 128, 0.18);
  height: 1px;
  box-shadow: none;
  border: none;
}

.item-info {
  padding: 10px 10px;
  display: flex;
  align-items: center;
  color: var(--color-text);
  cursor: default;
  img {
    height: 38px;
    width: 38px;
    border-radius: 4px;
  }
  .info {
    margin-left: 10px;
  }
  .title {
    font-size: 16px;
    font-weight: 600;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 1;
    overflow: hidden;
    word-break: break-all;
  }
  .subtitle {
    font-size: 12px;
    opacity: 0.68;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 1;
    overflow: hidden;
    word-break: break-all;
  }
}
</style>


================================================
FILE: src/components/Cover.vue
================================================
<template>
  <div
    class="cover"
    :class="{ 'cover-hover': coverHover }"
    @mouseover="focus = true"
    @mouseleave="focus = false"
    @click="clickCoverToPlay ? play() : goTo()"
  >
    <div class="cover-container">
      <div class="shade">
        <button
          v-show="focus"
          class="play-button"
          :style="playButtonStyles"
          @click.stop="play()"
          ><svg-icon icon-class="play" />
        </button>
      </div>
      <img :src="imageUrl" :style="imageStyles" loading="lazy" />
      <transition v-if="coverHover || alwaysShowShadow" name="fade">
        <div
          v-show="focus || alwaysShowShadow"
          class="shadow"
          :style="shadowStyles"
        ></div>
      </transition>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    id: { type: Number, required: true },
    type: { type: String, required: true },
    imageUrl: { type: String, required: true },
    fixedSize: { type: Number, default: 0 },
    playButtonSize: { type: Number, default: 22 },
    coverHover: { type: Boolean, default: true },
    alwaysShowPlayButton: { type: Boolean, default: true },
    alwaysShowShadow: { type: Boolean, default: false },
    clickCoverToPlay: { type: Boolean, default: false },
    shadowMargin: { type: Number, default: 12 },
    radius: { type: Number, default: 12 },
  },
  data() {
    return {
      focus: false,
    };
  },
  computed: {
    imageStyles() {
      let styles = {};
      if (this.fixedSize !== 0) {
        styles.width = this.fixedSize + 'px';
        styles.height = this.fixedSize + 'px';
      }
      if (this.type === 'artist') styles.borderRadius = '50%';
      return styles;
    },
    playButtonStyles() {
      let styles = {};
      styles.width = this.playButtonSize + '%';
      styles.height = this.playButtonSize + '%';
      return styles;
    },
    shadowStyles() {
      let styles = {};
      styles.backgroundImage = `url(${this.imageUrl})`;
      if (this.type === 'artist') styles.borderRadius = '50%';
      return styles;
    },
  },
  methods: {
    play() {
      const player = this.$store.state.player;
      const playActions = {
        album: player.playAlbumByID,
        playlist: player.playPlaylistByID,
        artist: player.playArtistByID,
      };
      playActions[this.type].bind(player)(this.id);
    },
    goTo() {
      this.$router.push({ name: this.type, params: { id: this.id } });
    },
  },
};
</script>

<style lang="scss" scoped>
.cover {
  position: relative;
  transition: transform 0.3s;
}
.cover-container {
  position: relative;
}
img {
  border-radius: 0.75em;
  width: 100%;
  user-select: none;
  aspect-ratio: 1 / 1;
  border: 1px solid rgba(0, 0, 0, 0.04);
}

.cover-hover {
  &:hover {
    cursor: pointer;
    /* transform: scale(1.02); */
  }
}

.shade {
  position: absolute;
  top: 0;
  height: calc(100% - 3px);
  width: 100%;
  background: transparent;
  display: flex;
  justify-content: center;
  align-items: center;
}
.play-button {
  display: flex;
  justify-content: center;
  align-items: center;
  color: white;
  backdrop-filter: blur(8px);
  background: rgba(255, 255, 255, 0.14);
  border: 1px solid rgba(255, 255, 255, 0.08);
  height: 22%;
  width: 22%;
  border-radius: 50%;
  cursor: default;
  transition: 0.2s;
  .svg-icon {
    width: 50%;
    margin: {
      left: 4px;
    }
  }
  &:hover {
    background: rgba(255, 255, 255, 0.28);
  }
  &:active {
    transform: scale(0.94);
  }
}

.shadow {
  position: absolute;
  top: 12px;
  height: 100%;
  width: 100%;
  filter: blur(16px) opacity(0.6);
  transform: scale(0.92, 0.96);
  z-index: -1;
  background-size: cover;
  border-radius: 0.75em;
  aspect-ratio: 1 / 1;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>


================================================
FILE: src/components/CoverRow.vue
================================================
<template>
  <div class="cover-row" :style="rowStyles">
    <div
      v-for="item in items"
      :key="item.id"
      class="item"
      :class="{ artist: type === 'artist' }"
    >
      <Cover
        :id="item.id"
        :image-url="getImageUrl(item)"
        :type="type"
        :play-button-size="type === 'artist' ? 26 : playButtonSize"
      />
      <div class="text">
        <div v-if="showPlayCount" class="info">
          <span class="play-count"
            ><svg-icon icon-class="play" />{{
              item.playCount | formatPlayCount
            }}
          </span>
        </div>
        <div class="title" :style="{ fontSize: subTextFontSize }">
          <span v-if="isExplicit(item)" class="explicit-symbol"
            ><ExplicitSymbol
          /></span>
          <span v-if="isPrivacy(item)" class="lock-icon">
            <svg-icon icon-class="lock"
          /></span>
          <router-link :to="getTitleLink(item)">{{ item.name }}</router-link>
        </div>
        <div v-if="type !== 'artist' && subText !== 'none'" class="info">
          <span v-html="getSubText(item)"></span>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import Cover from '@/components/Cover.vue';
import ExplicitSymbol from '@/components/ExplicitSymbol.vue';

export default {
  name: 'CoverRow',
  components: {
    Cover,
    ExplicitSymbol,
  },
  props: {
    items: { type: Array, required: true },
    type: { type: String, required: true },
    subText: { type: String, default: 'none' },
    subTextFontSize: { type: String, default: '16px' },
    showPlayCount: { type: Boolean, default: false },
    columnNumber: { type: Number, default: 5 },
    gap: { type: String, default: '44px 24px' },
    playButtonSize: { type: Number, default: 22 },
  },
  computed: {
    rowStyles() {
      return {
        'grid-template-columns': `repeat(${this.columnNumber}, 1fr)`,
        gap: this.gap,
      };
    },
  },
  methods: {
    getSubText(item) {
      if (this.subText === 'copywriter') return item.copywriter;
      if (this.subText === 'description') return item.description;
      if (this.subText === 'updateFrequency') return item.updateFrequency;
      if (this.subText === 'creator') return 'by ' + item.creator.nickname;
      if (this.subText === 'releaseYear')
        return new Date(item.publishTime).getFullYear();
      if (this.subText === 'artist') {
        if (item.artist !== undefined)
          return `<a href="/artist/${item.artist.id}">${item.artist.name}</a>`;
        if (item.artists !== undefined)
          return `<a href="/artist/${item.artists[0].id}">${item.artists[0].name}</a>`;
      }
      if (this.subText === 'albumType+releaseYear') {
        let albumType = item.type;
        if (item.type === 'EP/Single') {
          albumType = item.size === 1 ? 'Single' : 'EP';
        } else if (item.type === 'Single') {
          albumType = 'Single';
        } else if (item.type === '专辑') {
          albumType = 'Album';
        }
        return `${albumType} · ${new Date(item.publishTime).getFullYear()}`;
      }
      if (this.subText === 'appleMusic') return 'by Apple Music';
    },
    isPrivacy(item) {
      return this.type === 'playlist' && item.privacy === 10;
    },
    isExplicit(item) {
      return this.type === 'album' && (item.mark & 1048576) === 1048576;
    },
    getTitleLink(item) {
      return `/${this.type}/${item.id}`;
    },
    getImageUrl(item) {
      if (item.img1v1Url) {
        let img1v1ID = item.img1v1Url.split('/');
        img1v1ID = img1v1ID[img1v1ID.length - 1];
        if (img1v1ID === '5639395138885805.jpg') {
          // 没有头像的歌手,网易云返回的img1v1Url并不是正方形的 😅😅😅
          return 'https://p2.music.126.net/VnZiScyynLG7atLIZ2YPkw==/18686200114669622.jpg?param=512y512';
        }
      }
      let img = item.img1v1Url || item.picUrl || item.coverImgUrl;
      return `${img?.replace('http://', 'https://')}?param=512y512`;
    },
  },
};
</script>

<style lang="scss" scoped>
.cover-row {
  display: grid;
}

.item {
  color: var(--color-text);
  .text {
    margin-top: 8px;
    .title {
      font-size: 16px;
      font-weight: 600;
      line-height: 20px;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      overflow: hidden;
      word-break: break-all;
    }
    .info {
      font-size: 12px;
      opacity: 0.68;
      line-height: 18px;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      overflow: hidden;
      word-break: break-word;
    }
  }
}

.item.artist {
  display: flex;
  flex-direction: column;
  text-align: center;
  .cover {
    display: flex;
  }
  .title {
    margin-top: 4px;
  }
}

@media (max-width: 834px) {
  .item .text .title {
    font-size: 14px;
  }
}

.explicit-symbol {
  opacity: 0.28;
  color: var(--color-text);
  float: right;
  .svg-icon {
    margin-bottom: -3px;
  }
}

.lock-icon {
  opacity: 0.28;
  color: var(--color-text);
  margin-right: 4px;
  // float: right;
  .svg-icon {
    height: 12px;
    width: 12px;
  }
}

.play-count {
  font-weight: 600;
  opacity: 0.58;
  color: var(--color-text);
  font-size: 12px;
  .svg-icon {
    margin-right: 3px;
    height: 8px;
    width: 8px;
  }
}
</style>


================================================
FILE: src/components/DailyTracksCard.vue
================================================
<template>
  <div class="daily-recommend-card" @click="goToDailyTracks">
    <img :src="coverUrl" loading="lazy" />
    <div class="container">
      <div class="title-box">
        <div class="title">
          <span>每</span>
          <span>日</span>
          <span>推</span>
          <span>荐</span>
        </div>
      </div>
    </div>
    <button class="play-button" @click.stop="playDailyTracks">
      <svg-icon icon-class="play" />
    </button>
  </div>
</template>

<script>
import locale from '@/locale';
import { mapMutations, mapState, mapActions } from 'vuex';
import { dailyRecommendTracks } from '@/api/playlist';
import { isAccountLoggedIn } from '@/utils/auth';
import sample from 'lodash/sample';

const defaultCovers = [
  'https://p2.music.126.net/0-Ybpa8FrDfRgKYCTJD8Xg==/109951164796696795.jpg',
  'https://p2.music.126.net/QxJA2mr4hhb9DZyucIOIQw==/109951165422200291.jpg',
  'https://p1.music.126.net/AhYP9TET8l-VSGOpWAKZXw==/109951165134386387.jpg',
];

export default {
  name: 'DailyTracksCard',
  data() {
    return { useAnimation: false };
  },
  computed: {
    ...mapState(['dailyTracks']),
    coverUrl() {
      return `${
        this.dailyTracks[0]?.al.picUrl || sample(defaultCovers)
      }?param=1024y1024`;
    },
  },
  created() {
    if (this.dailyTracks.length === 0) this.loadDailyTracks();
  },
  methods: {
    ...mapActions(['showToast']),
    ...mapMutations(['updateDailyTracks']),
    loadDailyTracks() {
      if (!isAccountLoggedIn()) return;
      dailyRecommendTracks()
        .then(result => {
          this.updateDailyTracks(result.data.dailySongs);
        })
        .catch(() => {});
    },
    goToDailyTracks() {
      this.$router.push({ name: 'dailySongs' });
    },
    playDailyTracks() {
      if (!isAccountLoggedIn()) {
        this.showToast(locale.t('toast.needToLogin'));
        return;
      }
      let trackIDs = this.dailyTracks.map(t => t.id);
      this.$store.state.player.replacePlaylist(
        trackIDs,
        '/daily/songs',
        'url',
        this.dailyTracks[0].id
      );
    },
  },
};
</script>

<style lang="scss" scoped>
.daily-recommend-card {
  border-radius: 1rem;
  height: 198px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  z-index: 1;
}

img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  animation: move 38s infinite;
  animation-direction: alternate;
  z-index: -1;
}

.container {
  background: linear-gradient(to left, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.28));
  height: 198px;
  width: 50%;
  display: flex;
  align-items: center;
  border-radius: 0.94rem;
}

.title-box {
  height: 148px;
  width: 148px;
  color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  margin-left: 25px;
  user-select: none;
  .title {
    height: 100%;
    width: 100%;
    font-weight: 600;
    font-size: 64px;
    line-height: 48px;
    opacity: 0.96;
    display: grid;
    grid-template-columns: 1fr 1fr;
    justify-items: center;
    place-items: center;
  }
}

.play-button {
  backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255, 0.08);
  color: white;
  position: absolute;
  right: 1.6rem;
  bottom: 1.4rem;
  background: rgba(255, 255, 255, 0.14);
  border-radius: 50%;
  margin-bottom: 2px;
  display: flex;
  justify-content: center;
  align-items: center;
  height: 44px;
  width: 44px;
  transition: 0.2s;
  cursor: default;

  .svg-icon {
    margin-left: 4px;
    height: 16px;
    width: 16px;
  }

  &:hover {
    background: rgba(255, 255, 255, 0.44);
  }
  &:active {
    transform: scale(0.94);
  }
}

@keyframes move {
  0% {
    transform: translateY(0);
  }
  100% {
    transform: translateY(-50%);
  }
}
</style>


================================================
FILE: src/components/ExplicitSymbol.vue
================================================
<template>
  <svg-icon icon-class="explicit" :style="svgStyle"></svg-icon>
</template>

<script>
import SvgIcon from '@/components/SvgIcon.vue';

export default {
  name: 'ExplicitSymbol',
  components: {
    SvgIcon,
  },
  props: {
    size: {
      type: Number,
      default: 16,
    },
  },
  data() {
    return {
      svgStyle: {},
    };
  },
  created() {
    this.svgStyle = {
      height: this.size + 'px',
      width: this.size + 'px',
      position: 'relative',
      left: '-1px',
    };
  },
};
</script>

<style lang="scss" scoped></style>


================================================
FILE: src/components/FMCard.vue
================================================
<template>
  <div class="fm" :style="{ background }" data-theme="dark">
    <img :src="nextTrackCover" style="display: none" loading="lazy" />
    <img
      class="cover"
      :src="track.album && track.album.picUrl | resizeImage(512)"
      loading="lazy"
      @click="goToAlbum"
    />
    <div class="right-part">
      <div class="info">
        <div class="title">{{ track.name }}</div>
        <div class="artist"><ArtistsInLine :artists="artists" /></div>
      </div>
      <div class="controls">
        <div class="buttons">
          <button-icon title="不喜欢" @click.native="moveToFMTrash">
            <svg-icon id="thumbs-down" icon-class="thumbs-down" />
          </button-icon>
          <button-icon
            :title="$t(isPlaying ? 'player.pause' : 'player.play')"
            class="play"
            @click.native="play"
          >
            <svg-icon :icon-class="isPlaying ? 'pause' : 'play'" />
          </button-icon>
          <button-icon :title="$t('player.next')" @click.native="next">
            <svg-icon icon-class="next" />
          </button-icon>
        </div>
        <div class="card-name"><svg-icon icon-class="fm" />私人FM</div>
      </div>
    </div>
  </div>
</template>

<script>
import ButtonIcon from '@/components/ButtonIcon.vue';
import ArtistsInLine from '@/components/ArtistsInLine.vue';
import { mapState } from 'vuex';
import * as Vibrant from 'node-vibrant/dist/vibrant.worker.min.js';
import Color from 'color';

export default {
  name: 'FMCard',
  components: { ButtonIcon, ArtistsInLine },
  data() {
    return {
      background: '',
    };
  },
  computed: {
    ...mapState(['player']),
    track() {
      return this.player.personalFMTrack;
    },
    isPlaying() {
      return this.player.playing && this.player.isPersonalFM;
    },
    artists() {
      return this.track.artists || this.track.ar || [];
    },
    nextTrackCover() {
      return `${this.player._personalFMNextTrack?.album?.picUrl.replace(
        'http://',
        'https://'
      )}?param=512y512`;
    },
  },
  watch: {
    track() {
      this.getColor();
    },
  },
  created() {
    this.getColor();
    window.ok = this.getColor;
  },
  methods: {
    play() {
      this.player.playPersonalFM();
    },
    next() {
      this.player.playNextFMTrack();
    },
    goToAlbum() {
      if (this.track.album.id === 0) return;
      this.$router.push({ path: '/album/' + this.track.album.id });
    },
    moveToFMTrash() {
      this.player.moveToFMTrash();
    },
    getColor() {
      if (!this.player.personalFMTrack?.album?.picUrl) return;
      const cover = `${this.player.personalFMTrack.album.picUrl.replace(
        'http://',
        'https://'
      )}?param=512y512`;
      Vibrant.from(cover, { colorCount: 1 })
        .getPalette()
        .then(palette => {
          const color = Color.rgb(palette.Vibrant._rgb)
            .darken(0.1)
            .rgb()
            .string();
          const color2 = Color.rgb(palette.Vibrant._rgb)
            .lighten(0.28)
            .rotate(-30)
            .rgb()
            .string();
          this.background = `linear-gradient(to top left, ${color}, ${color2})`;
        });
    },
  },
};
</script>

<style lang="scss" scoped>
.fm {
  padding: 1rem;
  background: var(--color-secondary-bg);
  border-radius: 1rem;
  display: flex;
  height: 198px;
  box-sizing: border-box;
}
.cover {
  height: 100%;
  clip-path: border-box;
  border-radius: 0.75rem;
  margin-right: 1.2rem;
  cursor: pointer;
  user-select: none;
}
.right-part {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  color: var(--color-text);
  width: 100%;
  .title {
    font-size: 1.6rem;
    font-weight: 600;
    margin-bottom: 0.6rem;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    word-break: break-all;
  }
  .artist {
    opacity: 0.68;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    word-break: break-all;
  }
  .controls {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    margin-left: -0.4rem;
    .buttons {
      display: flex;
    }
    .button-icon {
      margin: 0 8px 0 0;
    }
    .svg-icon {
      width: 24px;
      height: 24px;
    }
    .svg-icon#thumbs-down {
      width: 22px;
      height: 22px;
    }
    .card-name {
      font-size: 1rem;
      opacity: 0.18;
      display: flex;
      align-items: center;
      font-weight: 600;
      user-select: none;
      .svg-icon {
        width: 18px;
        height: 18px;
        margin-right: 6px;
      }
    }
  }
}
</style>


================================================
FILE: src/components/LinuxTitlebar.vue
================================================
<template>
  <div class="linux-titlebar">
    <div class="logo">
      <img src="img/logos/yesplaymusic-white24x24.png" />
    </div>
    <div class="title">{{ title }}</div>
    <div class="controls">
      <div
        class="button minimize codicon codicon-chrome-minimize"
        @click="windowMinimize"
      ></div>
      <div
        class="button max-restore codicon"
        :class="{
          'codicon-chrome-restore': isMaximized,
          'codicon-chrome-maximize': !isMaximized,
        }"
        @click="windowMaxRestore"
      ></div>
      <div
        class="button close codicon codicon-chrome-close"
        @click="windowClose"
      ></div>
    </div>
  </div>
</template>

<script>
// icons by https://github.com/microsoft/vscode-codicons
import 'vscode-codicons/dist/codicon.css';

import { mapState } from 'vuex';

const electron =
  process.env.IS_ELECTRON === true ? window.require('electron') : null;
const ipcRenderer =
  process.env.IS_ELECTRON === true ? electron.ipcRenderer : null;

export default {
  name: 'LinuxTitlebar',
  data() {
    return {
      isMaximized: false,
    };
  },
  computed: {
    ...mapState(['title']),
  },
  created() {
    if (process.env.IS_ELECTRON === true) {
      ipcRenderer.on('isMaximized', (_, value) => {
        this.isMaximized = value;
      });
    }
  },
  methods: {
    windowMinimize() {
      ipcRenderer.send('minimize');
    },
    windowMaxRestore() {
      ipcRenderer.send('maximizeOrUnmaximize');
    },
    windowClose() {
      ipcRenderer.send('close');
    },
  },
};
</script>

<style lang="scss" scoped>
.linux-titlebar {
  color: var(--color-text);
  position: fixed;
  left: 0;
  top: 0;
  right: 0;
  -webkit-app-region: drag;
  display: flex;
  align-items: center;
  --hover: #e6e6e6;
  --active: #cccccc;

  .logo {
    padding: 0 8px;
  }

  .title {
    padding: 8px;
    font-size: 12px;
    font-family: 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', sans-serif;
    justify-self: center;
    margin: 0 auto;
  }
  .controls {
    height: 32px;
    //margin-left: auto;
    justify-content: flex-end;
    display: flex;
    .button {
      height: 100%;
      width: 46px;
      font-size: 16px;
      display: flex;
      justify-content: center;
      align-items: center;
      -webkit-app-region: no-drag;
      &:hover {
        background: var(--hover);
      }
      &:active {
        background: var(--active);
      }
      &.close {
        &:hover {
          background: #c42c1b;
          color: rgba(255, 255, 255, 0.8);
        }
        &:active {
          background: #f1707a;
          color: #000;
        }
      }
    }
  }
}
[data-theme='dark'] .linux-titlebar {
  --hover: #191919;
  --active: #333333;
}
</style>


================================================
FILE: src/components/Modal.vue
================================================
<template>
  <div v-show="show" class="shade" @click="clickOutside">
    <div class="modal" :style="modalStyles" @click.stop>
      <div class="header">
        <div class="title">{{ title }}</div>
        <button class="close" @click="close"
          ><svg-icon icon-class="x"
        /></button>
      </div>
      <div class="content"><slot></slot></div>
      <div v-if="showFooter" class="footer">
        <!-- <button>取消</button>
        <button class="primary">确定</button> -->
        <slot name="footer"></slot>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'Modal',
  props: {
    show: Boolean,
    close: Function,
    title: {
      type: String,
      default: 'Title',
    },
    showFooter: {
      type: Boolean,
      default: true,
    },
    width: {
      type: String,
      default: '50vw',
    },
    clickOutsideHide: {
      type: Boolean,
      default: false,
    },
    minWidth: {
      type: String,
      default: 'calc(min(23rem, 100vw))',
    },
  },
  computed: {
    modalStyles() {
      return {
        width: this.width,
        minWidth: this.minWidth,
      };
    },
  },
  methods: {
    clickOutside() {
      if (this.clickOutsideHide) {
        this.close();
      }
    },
  },
};
</script>

<style lang="scss" scoped>
.shade {
  background: rgba(255, 255, 255, 0.58);
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}

.modal {
  background: rgba(255, 255, 255, 0.78);
  box-shadow: 0 12px 16px -8px rgba(0, 0, 0, 0.1);
  border: 1px solid rgba(0, 0, 0, 0.08);
  backdrop-filter: blur(12px) opacity(1);
  padding: 24px 0;
  border-radius: 12px;
  width: 50vw;
  margin: auto 0;
  font-size: 14px;
  z-index: 100;
  display: flex;
  flex-direction: column;
  max-height: calc(100vh - 128px - 64px);

  ::-webkit-scrollbar {
    width: 4px;
  }
  ::-webkit-scrollbar-track {
    background: transparent;
    border: unset;
    width: 0;
  }
  ::-webkit-scrollbar-thumb {
    background: var(--color-secondary-bg-for-transparent);
  }
}

@supports (-moz-appearance: none) {
  .modal {
    background: var(--color-body-bg) !important;
  }
}

.content {
  overflow: auto;
  overflow-x: hidden;
  padding: 0 24px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin: 0 24px 24px 24px;
  .title {
    font-weight: 600;
    font-size: 20px;
  }
  button {
    color: var(--color-text);
    border-radius: 50%;
    height: 32px;
    width: 32px;
    display: flex;
    justify-content: center;
    align-items: center;
    opacity: 0.68;
    transition: 0.2s;
    &:hover {
      opacity: 1;
      background: var(--color-secondary-bg-for-transparent);
    }
  }
  .svg-icon {
    height: 18px;
    width: 18px;
  }
}

.footer {
  padding-top: 16px;
  margin: 16px 24px 24px 24px;
  border-top: 1px solid rgba(128, 128, 128, 0.18);
  display: flex;
  justify-content: flex-end;
  margin-bottom: -8px;
  button {
    color: var(--color-text);
    background: var(--color-secondary-bg-for-transparent);
    border-radius: 8px;
    padding: 6px 16px;
    font-size: 14px;
    margin-left: 12px;
    transition: 0.2s;
    &:active {
      transform: scale(0.94);
    }
  }
  button.primary {
    color: var(--color-primary-bg);
    background: var(--color-primary);
    font-weight: 500;
  }
  button.block {
    width: 100%;
    margin-left: 0;
    &:active {
      transform: scale(0.98);
    }
  }
}

[data-theme='dark'] {
  .shade {
    background: rgba(0, 0, 0, 0.38);
    color: var(--color-text);
  }

  .modal {
    background: rgba(36, 36, 36, 0.88);
    border: 1px solid rgba(255, 255, 255, 0.08);
  }
}
</style>


================================================
FILE: src/components/ModalAddTrackToPlaylist.vue
================================================
<template>
  <Modal
    class="add-track-to-playlist-modal"
    :show="show"
    :close="close"
    :show-footer="false"
    title="添加到歌单"
    width="25vw"
  >
    <template slot="default">
      <div class="new-playlist-button" @click="newPlaylist"
        ><svg-icon icon-class="plus" />新建歌单</div
      >
      <div
        v-for="playlist in ownPlaylists"
        :key="playlist.id"
        class="playlist"
        @click="addTrackToPlaylist(playlist.id)"
      >
        <img :src="playlist.coverImgUrl | resizeImage(224)" loading="lazy" />
        <div class="info">
          <div class="title">{{ playlist.name }}</div>
          <div class="track-count">{{ playlist.trackCount }} 首</div>
        </div>
      </div>
    </template>
  </Modal>
</template>

<script>
import { mapActions, mapMutations, mapState } from 'vuex';
import Modal from '@/components/Modal.vue';
import locale from '@/locale';
import { addOrRemoveTrackFromPlaylist } from '@/api/playlist';

export default {
  name: 'ModalAddTrackToPlaylist',
  components: {
    Modal,
  },
  data() {
    return {
      playlists: [],
    };
  },
  computed: {
    ...mapState(['modals', 'data', 'liked']),
    show: {
      get() {
        return this.modals.addTrackToPlaylistModal.show;
      },
      set(value) {
        this.updateModal({
          modalName: 'addTrackToPlaylistModal',
          key: 'show',
          value,
        });
        if (value) {
          this.$store.commit('enableScrolling', false);
        } else {
          this.$store.commit('enableScrolling', true);
        }
      },
    },
    ownPlaylists() {
      return this.liked.playlists.filter(
        p =>
          p.creator.userId === this.data.user.userId &&
          p.id !== this.data.likedSongPlaylistID
      );
    },
  },
  methods: {
    ...mapMutations(['updateModal']),
    ...mapActions(['showToast']),
    close() {
      this.show = false;
    },
    addTrackToPlaylist(playlistID) {
      addOrRemoveTrackFromPlaylist({
        op: 'add',
        pid: playlistID,
        tracks: this.modals.addTrackToPlaylistModal.selectedTrackID,
      }).then(data => {
        if (data.body.code === 200) {
          this.show = false;
          this.showToast(locale.t('toast.savedToPlaylist'));
        } else {
          this.showToast(data.body.message);
        }
      });
    },
    newPlaylist() {
      this.updateModal({
        modalName: 'newPlaylistModal',
        key: 'afterCreateAddTrackID',
        value: this.modals.addTrackToPlaylistModal.selectedTrackID,
      });
      this.close();
      this.updateModal({
        modalName: 'newPlaylistModal',
        key: 'show',
        value: true,
      });
    },
  },
};
</script>

<style lang="scss" scoped>
.new-playlist-button {
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 16px;
  font-weight: 500;
  color: var(--color-text);
  background: var(--color-secondary-bg-for-transparent);
  border-radius: 8px;
  height: 48px;
  margin-bottom: 16px;
  margin-right: 6px;
  margin-left: 6px;
  cursor: pointer;
  transition: 0.2s;
  .svg-icon {
    width: 16px;
    height: 16px;
    margin-right: 8px;
  }
  &:hover {
    color: var(--color-primary);
    background: var(--color-primary-bg-for-transparent);
  }
}
.playlist {
  display: flex;
  padding: 6px;
  border-radius: 8px;
  cursor: pointer;
  &:hover {
    background: var(--color-secondary-bg-for-transparent);
  }
  img {
    border-radius: 8px;
    height: 42px;
    width: 42px;
    margin-right: 12px;
    border: 1px solid rgba(0, 0, 0, 0.04);
  }
  .info {
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .title {
    font-size: 16px;
    font-weight: 500;
    color: var(--color-text);
    padding-right: 16px;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 1;
    overflow: hidden;
    word-break: break-all;
  }
  .track-count {
    margin-top: 2px;
    font-size: 13px;
    opacity: 0.68;
    color: var(--color-text);
  }
}
</style>


================================================
FILE: src/components/ModalNewPlaylist.vue
================================================
<template>
  <Modal
    class="add-playlist-modal"
    :show="show"
    :close="close"
    title="新建歌单"
    width="25vw"
  >
    <template slot="default">
      <input
        v-model="title"
        type="text"
        placeholder="歌单标题"
        maxlength="40"
      />
      <div class="checkbox">
        <input
          id="checkbox-private"
          v-model="privatePlaylist"
          type="checkbox"
        />
        <label for="checkbox-private">设置为隐私歌单</label>
      </div>
    </template>
    <template slot="footer">
      <button class="primary block" @click="createPlaylist">创建</button>
    </template>
  </Modal>
</template>

<script>
import Modal from '@/components/Modal.vue';
import locale from '@/locale';
import { mapMutations, mapState, mapActions } from 'vuex';
import { createPlaylist, addOrRemoveTrackFromPlaylist } from '@/api/playlist';

export default {
  name: 'ModalNewPlaylist',
  components: {
    Modal,
  },
  data() {
    return {
      title: '',
      privatePlaylist: false,
    };
  },
  computed: {
    ...mapState(['modals']),
    show: {
      get() {
        return this.modals.newPlaylistModal.show;
      },
      set(value) {
        this.updateModal({
          modalName: 'newPlaylistModal',
          key: 'show',
          value,
        });
        if (value) {
          this.$store.commit('enableScrolling', false);
        } else {
          this.$store.commit('enableScrolling', true);
        }
      },
    },
  },
  methods: {
    ...mapMutations(['updateModal', 'updateData']),
    ...mapActions(['showToast', 'fetchLikedPlaylist']),
    close() {
      this.show = false;
      this.title = '';
      this.privatePlaylist = false;
      this.resetAfterCreateAddTrackID();
    },
    createPlaylist() {
      let params = { name: this.title };
      if (this.private) params.type = 10;
      createPlaylist(params).then(data => {
        if (data.code === 200) {
          if (this.modals.newPlaylistModal.afterCreateAddTrackID !== 0) {
            addOrRemoveTrackFromPlaylist({
              op: 'add',
              pid: data.id,
              tracks: this.modals.newPlaylistModal.afterCreateAddTrackID,
            }).then(data => {
              if (data.body.code === 200) {
                this.showToast(locale.t('toast.savedToPlaylist'));
              } else {
                this.showToast(data.body.message);
              }
              this.resetAfterCreateAddTrackID();
            });
          }
          this.close();
          this.showToast('成功创建歌单');
          this.updateData({ key: 'libraryPlaylistFilter', value: 'mine' });
          this.fetchLikedPlaylist();
        }
      });
    },
    resetAfterCreateAddTrackID() {
      this.updateModal({
        modalName: 'newPlaylistModal',
        key: 'AfterCreateAddTrackID',
        value: 0,
      });
    },
  },
};
</script>

<style lang="scss" scoped>
.add-playlist-modal {
  .content {
    display: flex;
    flex-direction: column;
    input {
      margin-bottom: 12px;
    }
    input[type='text'] {
      width: calc(100% - 24px);
      flex: 1;
      background: var(--color-secondary-bg-for-transparent);
      font-size: 16px;
      border: none;
      font-weight: 600;
      padding: 8px 12px;
      border-radius: 8px;
      margin-top: -1px;
      color: var(--color-text);
      &:focus {
        background: var(--color-primary-bg-for-transparent);
        opacity: 1;
      }
      [data-theme='light'] &:focus {
        color: var(--color-primary);
      }
    }
    .checkbox {
      input[type='checkbox' i] {
        margin: 3px 3px 3px 4px;
      }
      display: flex;
      align-items: center;
      label {
        font-size: 12px;
      }
      user-select: none;
    }
  }
}
</style>


================================================
FILE: src/components/MvRow.vue
================================================
<template>
  <div class="mv-row" :class="{ 'without-padding': withoutPadding }">
    <div v-for="mv in mvs" :key="getID(mv)" class="mv">
      <div
        class="cover"
        @mouseover="hoverVideoID = getID(mv)"
        @mouseleave="hoverVideoID = 0"
        @click="goToMv(getID(mv))"
      >
        <img :src="getUrl(mv)" loading="lazy" />
        <transition name="fade">
          <div
            v-show="hoverVideoID === getID(mv)"
            class="shadow"
            :style="{ background: 'url(' + getUrl(mv) + ')' }"
          ></div>
        </transition>
      </div>
      <div class="info">
        <div class="title">
          <router-link :to="'/mv/' + getID(mv)">{{ getTitle(mv) }}</router-link>
        </div>
        <div class="artist" v-html="getSubtitle(mv)"></div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CoverVideo',
  props: {
    mvs: Array,
    subtitle: {
      type: String,
      default: 'artist',
    },
    withoutPadding: { type: Boolean, default: false },
  },
  data() {
    return {
      hoverVideoID: 0,
    };
  },
  methods: {
    goToMv(id) {
      let query = {};
      if (this.$parent.player !== undefined) {
        query = { autoplay: this.$parent.player.playing };
      }
      this.$router.push({ path: '/mv/' + id, query });
    },
    getUrl(mv) {
      let url = mv.imgurl16v9 ?? mv.cover ?? mv.coverUrl;
      return url.replace(/^http:/, 'https:') + '?param=464y260';
    },
    getID(mv) {
      if (mv.id !== undefined) return mv.id;
      if (mv.vid !== undefined) return mv.vid;
    },
    getTitle(mv) {
      if (mv.name !== undefined) return mv.name;
      if (mv.title !== undefined) return mv.title;
    },
    getSubtitle(mv) {
      if (this.subtitle === 'artist') {
        let artistName = 'null';
        let artistID = 0;
        if (mv.artistName !== undefined) {
          artistName = mv.artistName;
          artistID = mv.artistId;
        } else if (mv.creator !== undefined) {
          artistName = mv.creator[0].userName;
          artistID = mv.creator[0].userId;
        }
        return `<a href="/artist/${artistID}">${artistName}</a>`;
      } else if (this.subtitle === 'publishTime') {
        return mv.publishTime;
      }
    },
  },
};
</script>

<style lang="scss" scoped>
.mv-row {
  --col-num: 5;
  display: grid;
  grid-template-columns: repeat(var(--col-num), 1fr);
  gap: 36px 24px;
  padding: var(--main-content-padding);
}

.mv-row.without-padding {
  padding: 0;
}

@media (max-width: 900px) {
  .mv-row {
    --col-num: 4;
  }
}

@media (max-width: 800px) {
  .mv-row {
    --col-num: 3;
  }
}

@media (max-width: 700px) {
  .mv-row {
    --col-num: 2;
  }
}

@media (max-width: 550px) {
  .mv-row {
    --col-num: 1;
  }
}

.mv {
  color: var(--color-text);

  .title {
    font-size: 16px;
    font-weight: 600;
    opacity: 0.88;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    word-break: break-all;
  }
  .artist {
    font-size: 12px;
    opacity: 0.68;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
  }
}

.cover {
  position: relative;
  transition: transform 0.3s;
  &:hover {
    cursor: pointer;
  }
}
img {
  border-radius: 0.75em;
  width: 100%;
  user-select: none;
}

.shadow {
  position: absolute;
  top: 6px;
  height: 100%;
  width: 100%;
  filter: blur(16px) opacity(0.4);
  transform: scale(0.9, 0.9);
  z-index: -1;
  background-size: cover;
  border-radius: 0.75em;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>


================================================
FILE: src/components/Navbar.vue
================================================
<template>
  <div>
    <nav :class="{ 'has-custom-titlebar': hasCustomTitlebar }">
      <Win32Titlebar v-if="enableWin32Titlebar" />
      <LinuxTitlebar v-if="enableLinuxTitlebar" />
      <div class="navigation-buttons">
        <button-icon @click.native="go('back')"
          ><svg-icon icon-class="arrow-left"
        /></button-icon>
        <button-icon @click.native="go('forward')"
          ><svg-icon icon-class="arrow-right"
        /></button-icon>
      </div>
      <div class="navigation-links">
        <router-link to="/" :class="{ active: $route.name === 'home' }">{{
          $t('nav.home')
        }}</router-link>
        <router-link
          to="/explore"
          :class="{ active: $route.name === 'explore' }"
          >{{ $t('nav.explore') }}</router-link
        >
        <router-link
          to="/library"
          :class="{ active: $route.name === 'library' }"
          >{{ $t('nav.library') }}</router-link
        >
      </div>
      <div class="right-part">
        <div class="search-box">
          <div class="container" :class="{ active: inputFocus }">
            <svg-icon icon-class="search" />
            <div class="input">
              <input
                ref="searchInput"
                v-model="keywords"
                type="search"
                :placeholder="inputFocus ? '' : $t('nav.search')"
                @keydown.enter="doSearch"
                @focus="inputFocus = true"
                @blur="inputFocus = false"
              />
            </div>
          </div>
        </div>
        <img
          class="avatar"
          :src="avatarUrl"
          @click="showUserProfileMenu"
          loading="lazy"
        />
      </div>
    </nav>

    <ContextMenu ref="userProfileMenu">
      <div class="item" @click="toSettings">
        <svg-icon icon-class="settings" />
        {{ $t('library.userProfileMenu.settings') }}
      </div>
      <div v-if="!isLooseLoggedIn" class="item" @click="toLogin">
        <svg-icon icon-class="login" />
        {{ $t('login.login') }}
      </div>
      <div v-if="isLooseLoggedIn" class="item" @click="logout">
        <svg-icon icon-class="logout" />
        {{ $t('library.userProfileMenu.logout') }}
      </div>
      <hr />
      <div class="item" @click="toGitHub">
        <svg-icon icon-class="github" />
        {{ $t('nav.github') }}
      </div>
    </ContextMenu>
  </div>
</template>

<script>
import { mapState } from 'vuex';
import { isLooseLoggedIn, doLogout } from '@/utils/auth';

// import icons for win32 title bar
// icons by https://github.com/microsoft/vscode-codicons
import 'vscode-codicons/dist/codicon.css';

import Win32Titlebar from '@/components/Win32Titlebar.vue';
import LinuxTitlebar from '@/components/LinuxTitlebar.vue';
import ContextMenu from '@/components/ContextMenu.vue';
import ButtonIcon from '@/components/ButtonIcon.vue';

export default {
  name: 'Navbar',
  components: {
    Win32Titlebar,
    LinuxTitlebar,
    ButtonIcon,
    ContextMenu,
  },
  data() {
    return {
      inputFocus: false,
      langs: ['zh-CN', 'zh-TW', 'en', 'tr'],
      keywords: '',
      enableWin32Titlebar: false,
      enableLinuxTitlebar: false,
    };
  },
  computed: {
    ...mapState(['settings', 'data']),
    isLooseLoggedIn() {
      return isLooseLoggedIn();
    },
    avatarUrl() {
      return this.data?.user?.avatarUrl && this.isLooseLoggedIn
        ? `${this.data?.user?.avatarUrl}?param=512y512`
        : 'http://s4.music.126.net/style/web2/img/default/default_avatar.jpg?param=60y60';
    },
    hasCustomTitlebar() {
      return this.enableWin32Titlebar || this.enableLinuxTitlebar;
    },
  },
  created() {
    if (process.platform === 'win32') {
      this.enableWin32Titlebar = true;
    } else if (
      process.platform === 'linux' &&
      this.settings.linuxEnableCustomTitlebar
    ) {
      this.enableLinuxTitlebar = true;
    }
  },
  methods: {
    go(where) {
      if (where === 'back') this.$router.go(-1);
      else this.$router.go(1);
    },
    doSearch() {
      if (!this.keywords) return;
      if (
        this.$route.name === 'search' &&
        this.$route.params.keywords === this.keywords
      ) {
        return;
      }
      this.$router.push({
        name: 'search',
        params: { keywords: this.keywords },
      });
    },
    showUserProfileMenu(e) {
      this.$refs.userProfileMenu.openMenu(e);
    },
    logout() {
      if (!confirm('确定要退出登录吗?')) return;
      doLogout();
      this.$router.push({ name: 'home' });
    },
    toSettings() {
      this.$router.push({ name: 'settings' });
    },
    toGitHub() {
      window.open('https://github.com/qier222/YesPlayMusic');
    },
    toLogin() {
      if (process.env.IS_ELECTRON === true) {
        this.$router.push({ name: 'loginAccount' });
      } else {
        this.$router.push({ name: 'login' });
      }
    },
  },
};
</script>

<style lang="scss" scoped>
nav {
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
  padding: {
    right: 10vw;
    left: 10vw;
  }
  backdrop-filter: saturate(180%) blur(20px);

  background-color: var(--color-navbar-bg);
  z-index: 100;
  -webkit-app-region: drag;
}

@media (max-width: 1336px) {
  nav {
    padding: 0 max(5vw, 90px);
  }
}

@supports (-moz-appearance: none) {
  nav {
    background-color: var(--color-body-bg);
  }
}

nav.has-custom-titlebar {
  padding-top: 20px;
  -webkit-app-region: no-drag;
}

.navigation-buttons {
  flex: 1;
  display: flex;
  align-items: center;
  .svg-icon {
    height: 24px;
    width: 24px;
  }
  button {
    -webkit-app-region: no-drag;
  }
}
@media (max-width: 970px) {
  .navigation-buttons {
    flex: unset;
  }
}

.navigation-links {
  flex: 1;
  display: flex;
  justify-content: center;
  text-transform: uppercase;
  user-select: none;
  a {
    -webkit-app-region: no-drag;
    font-size: 18px;
    font-weight: 700;
    text-decoration: none;
    border-radius: 6px;
    padding: 6px 10px;
    color: var(--color-text);
    transition: 0.2s;
    -webkit-user-drag: none;
    margin: {
      right: 12px;
      left: 12px;
    }
    &:hover {
      background: var(--color-secondary-bg-for-transparent);
    }
    &:active {
      transform: scale(0.92);
      transition: 0.2s;
    }
  }
  a.active {
    color: var(--color-primary);
  }
}

.search {
  .svg-icon {
    height: 18px;
    width: 18px;
  }
}

.search-box {
  display: flex;
  justify-content: flex-end;
  -webkit-app-region: no-drag;

  .container {
    display: flex;
    align-items: center;
    height: 32px;
    background: var(--color-secondary-bg-for-transparent);
    border-radius: 8px;
    width: 200px;
  }

  .svg-icon {
    height: 15px;
    width: 15px;
    color: var(--color-text);
    opacity: 0.28;
    margin: {
      left: 8px;
      right: 4px;
    }
  }

  input {
    font-size: 16px;
    border: none;
    background: transparent;
    width: 96%;
    font-weight: 600;
    margin-top: -1px;
    color: var(--color-text);
  }

  .active {
    background: var(--color-primary-bg-for-transparent);
    input,
    .svg-icon {
      opacity: 1;
      color: var(--color-primary);
    }
  }
}

[data-theme='dark'] {
  .search-box {
    .active {
      input,
      .svg-icon {
        color: var(--color-text);
      }
    }
  }
}

.right-part {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  .avatar {
    user-select: none;
    height: 30px;
    margin-left: 12px;
    vertical-align: -7px;
    border-radius: 50%;
    cursor: pointer;
    -webkit-app-region: no-drag;
    -webkit-user-drag: none;
    &:hover {
      filter: brightness(80%);
    }
  }
  .search-button {
    display: none;
    -webkit-app-region: no-drag;
  }
}
</style>


================================================
FILE: src/components/Player.vue
================================================
<template>
  <div class="player" @click="handleClick" @mousedown="handleMouseDown">
    <div
      class="progress-bar"
      :class="{
        nyancat: settings.nyancatStyle,
        'nyancat-stop': settings.nyancatStyle && !player.playing,
      }"
      @click.stop
    >
      <vue-slider
        v-model="player.progress"
        :min="0"
        :max="player.currentTrackDuration"
        :interval="1"
        :drag-on-click="true"
        :duration="0"
        :dot-size="12"
        :height="2"
        :tooltip-formatter="formatTrackTime"
        :lazy="true"
        :silent="true"
      ></vue-slider>
    </div>
    <div class="controls">
      <div class="playing">
        <div class="container" @click.stop>
          <img
            :src="currentTrack.al && currentTrack.al.picUrl | resizeImage(224)"
            loading="lazy"
            @click="goToAlbum"
          />
          <div class="track-info" :title="audioSource">
            <div
              :class="['name', { 'has-list': hasList() }]"
              @click="hasList() && goToList()"
            >
              {{ currentTrack.name }}
            </div>
            <div class="artist">
              <span
                v-for="(ar, index) in currentTrack.ar"
                :key="ar.id"
                @click="ar.id && goToArtist(ar.id)"
              >
                <span :class="{ ar: ar.id }"> {{ ar.name }} </span
                ><span v-if="index !== currentTrack.ar.length - 1">, </span>
              </span>
            </div>
          </div>
          <div class="like-button">
            <button-icon
              :title="
                player.isCurrentTrackLiked
                  ? $t('player.unlike')
                  : $t('player.like')
              "
              @click.native="likeATrack(player.currentTrack.id)"
            >
              <svg-icon
                v-show="!player.isCurrentTrackLiked"
                icon-class="heart"
              ></svg-icon>
              <svg-icon
                v-show="player.isCurrentTrackLiked"
                icon-class="heart-solid"
              ></svg-icon>
            </button-icon>
          </div>
        </div>
        <div class="blank"></div>
      </div>
      <div class="middle-control-buttons">
        <div class="blank"></div>
        <div class="container" @click.stop>
          <button-icon
            v-show="!player.isPersonalFM"
            :title="$t('player.previous')"
            @click.native="playPrevTrack"
            ><svg-icon icon-class="previous"
          /></button-icon>
          <button-icon
            v-show="player.isPersonalFM"
            title="不喜欢"
            @click.native="moveToFMTrash"
            ><svg-icon icon-class="thumbs-down"
          /></button-icon>
          <button-icon
            class="play"
            :title="$t(player.playing ? 'player.pause' : 'player.play')"
            @click.native="playOrPause"
          >
            <svg-icon :icon-class="player.playing ? 'pause' : 'play'"
          /></button-icon>
          <button-icon :title="$t('player.next')" @click.native="playNextTrack"
            ><svg-icon icon-class="next"
          /></button-icon>
        </div>
        <div class="blank"></div>
      </div>
      <div class="right-control-buttons">
        <div class="blank"></div>
        <div class="container" @click.stop>
          <button-icon
            :title="$t('player.nextUp')"
            :class="{
              active: $route.name === 'next',
              disabled: player.isPersonalFM,
            }"
            @click.native="goToNextTracksPage"
            ><svg-icon icon-class="list"
          /></button-icon>
          <button-icon
            :class="{
              active: player.repeatMode !== 'off',
              disabled: player.isPersonalFM,
            }"
            :title="
              player.repeatMode === 'one'
                ? $t('player.repeatTrack')
                : $t('player.repeat')
            "
            @click.native="switchRepeatMode"
          >
            <svg-icon
              v-show="player.repeatMode !== 'one'"
              icon-class="repeat"
            />
            <svg-icon
              v-show="player.repeatMode === 'one'"
              icon-class="repeat-1"
            />
          </button-icon>
          <button-icon
            :class="{ active: player.shuffle, disabled: player.isPersonalFM }"
            :title="$t('player.shuffle')"
            @click.native="switchShuffle"
            ><svg-icon icon-class="shuffle"
          /></button-icon>
          <button-icon
            v-if="settings.enableReversedMode"
            :class="{ active: player.reversed, disabled: player.isPersonalFM }"
            :title="$t('player.reversed')"
            @click.native="switchReversed"
            ><svg-icon icon-class="sort-up"
          /></button-icon>
          <div class="volume-control">
            <button-icon :title="$t('player.mute')" @click.native="mute">
              <svg-icon v-show="volume > 0.5" icon-class="volume" />
              <svg-icon v-show="volume === 0" icon-class="volume-mute" />
              <svg-icon
                v-show="volume <= 0.5 && volume !== 0"
                icon-class="volume-half"
              />
            </button-icon>
            <div class="volume-bar">
              <vue-slider
                v-model="volume"
                :min="0"
                :max="1"
                :interval="0.01"
                :drag-on-click="true"
                :duration="0"
                tooltip="none"
                :dot-size="12"
              ></vue-slider>
            </div>
          </div>

          <button-icon
            class="lyrics-button"
            title="歌词"
            style="margin-left: 12px"
            @click.native="toggleLyrics"
            ><svg-icon icon-class="arrow-up"
          /></button-icon>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import '@/assets/css/slider.css';

import ButtonIcon from '@/components/ButtonIcon.vue';
import VueSlider from 'vue-slider-component';
import { goToListSource, hasListSource } from '@/utils/playList';
import { formatTrackTime } from '@/utils/common';

export default {
  name: 'Player',
  components: {
    ButtonIcon,
    VueSlider,
  },
  data() {
    return {
      mouseDownTarget: null,
    };
  },
  computed: {
    ...mapState(['player', 'settings', 'data']),
    currentTrack() {
      return this.player.currentTrack;
    },
    volume: {
      get() {
        return this.player.volume;
      },
      set(value) {
        this.player.volume = value;
      },
    },
    playing() {
      return this.player.playing;
    },
    audioSource() {
      return this.player._howler?._src.includes('kuwo.cn')
        ? '音源来自酷我音乐'
        : '';
    },
  },
  mounted() {
    this.setupMediaControls();
    window.addEventListener('keydown', this.handleKeydown);
  },
  beforeDestroy() {
    window.removeEventListener('keydown', this.handleKeydown);
  },
  methods: {
    ...mapMutations(['toggleLyrics']),
    ...mapActions(['showToast', 'likeATrack']),
    handleClick(event) {
      if (event.target == this.mouseDownTarget) {
        this.toggleLyrics();
      }
    },
    handleMouseDown(event) {
      this.mouseDownTarget = event.target;
    },
    playPrevTrack() {
      this.player.playPrevTrack();
    },
    playOrPause() {
      this.player.playOrPause();
    },
    playNextTrack() {
      if (this.player.isPersonalFM) {
        this.player.playNextFMTrack();
      } else {
        this.player.playNextTrack();
      }
    },
    goToNextTracksPage() {
      if (this.player.isPersonalFM) return;
      this.$route.name === 'next'
        ? this.$router.go(-1)
        : this.$router.push({ name: 'next' });
    },
    formatTrackTime(value) {
      return formatTrackTime(value);
    },
    hasList() {
      return hasListSource();
    },
    goToList() {
      goToListSource();
    },
    goToAlbum() {
      if (this.player.currentTrack.al.id === 0) return;
      this.$router.push({ path: '/album/' + this.player.currentTrack.al.id });
    },
    goToArtist(id) {
      this.$router.push({ path: '/artist/' + id });
    },
    moveToFMTrash() {
      this.player.moveToFMTrash();
    },
    switchRepeatMode() {
      this.player.switchRepeatMode();
    },
    switchShuffle() {
      this.player.switchShuffle();
    },
    switchReversed() {
      this.player.switchReversed();
    },
    mute() {
      this.player.mute();
    },

    setupMediaControls() {
      if ('mediaSession' in navigator) {
        navigator.mediaSession.setActionHandler('play', () => {
          this.playOrPause();
        });
        navigator.mediaSession.setActionHandler('pause', () => {
          this.playOrPause();
        });
        navigator.mediaSession.setActionHandler('previoustrack', () => {
          this.playPrevTrack();
        });
        navigator.mediaSession.setActionHandler('nexttrack', () => {
          this.playNextTrack();
        });
      }
    },

    handleKeydown(event) {
      switch (event.code) {
        case 'MediaPlayPause':
          this.playOrPause();
          break;
        case 'MediaTrackPrevious':
          this.playPrevTrack();
          break;
        case 'MediaTrackNext':
          this.playNextTrack();
          break;
        default:
          break;
      }
    },
  },
};
</script>

<style lang="scss" scoped>
.player {
  position: fixed;
  bottom: 0;
  right: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  height: 64px;
  backdrop-filter: saturate(180%) blur(30px);
  // background-color: rgba(255, 255, 255, 0.86);
  background-color: var(--color-navbar-bg);
  z-index: 100;
}

@supports (-moz-appearance: none) {
  .player {
    background-color: var(--color-body-bg);
  }
}

.progress-bar {
  margin-top: -6px;
  margin-bottom: -6px;
  width: 100%;
}

.controls {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  height: 100%;
  padding: {
    right: 10vw;
    left: 10vw;
  }
}

@media (max-width: 1336px) {
  .controls {
    padding: 0 5vw;
  }
}

.blank {
  flex-grow: 1;
}

.playing {
  display: flex;
}

.playing .container {
  display: flex;
  align-items: center;
  img {
    height: 46px;
    border-radius: 5px;
    box-shadow: 0 6px 8px -2px rgba(0, 0, 0, 0.16);
    cursor: pointer;
    user-select: none;
  }
  .track-info {
    height: 46px;
    margin-left: 12px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    .name {
      font-weight: 600;
      font-size: 16px;
      opacity: 0.88;
      color: var(--color-text);
      margin-bottom: 4px;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;
      overflow: hidden;
      word-break: break-all;
    }
    .has-list {
      cursor: pointer;
      &:hover {
        text-decoration: underline;
      }
    }
    .artist {
      font-size: 12px;
      opacity: 0.58;
      color: var(--color-text);
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;
      overflow: hidden;
      word-break: break-all;
      span.ar {
        cursor: pointer;
        &:hover {
          text-decoration: underline;
        }
      }
    }
  }
}

.middle-control-buttons {
  display: flex;
}

.middle-control-buttons .container {
  flex: 1;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 0 8px;
  .button-icon {
    margin: 0 8px;
  }
  .play {
    height: 42px;
    width: 42px;
    .svg-icon {
      width: 24px;
      height: 24px;
    }
  }
}

.right-control-buttons {
  display: flex;
}

.right-control-buttons .container {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  .expand {
    margin-left: 24px;
    .svg-icon {
      height: 24px;
      width: 24px;
    }
  }
  .active .svg-icon {
    color: var(--color-primary);
  }
  .volume-control {
    margin-left: 4px;
    display: flex;
    align-items: center;
    .volume-bar {
      width: 84px;
    }
  }
}

.like-button {
  margin-left: 16px;
}

.button-icon.disabled {
  cursor: default;
  opacity: 0.38;
  &:hover {
    background: none;
  }
  &:active {
    transform: unset;
  }
}
</style>


================================================
FILE: src/components/Scrollbar.vue
================================================
<template>
  <div>
    <transition name="fade">
      <div
        v-show="show"
        id="scrollbar"
        :class="{ 'on-drag': isOnDrag }"
        @click="handleClick"
      >
        <div
          id="thumbContainer"
          :class="{ active }"
          :style="thumbStyle"
          @mouseenter="handleMouseenter"
          @mouseleave="handleMouseleave"
          @mousedown="handleDragStart"
          @click.stop
        >
          <div></div>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'Scrollbar',
  data() {
    return {
      top: 0,
      thumbHeight: 0,
      active: false,
      show: false,
      hideTimer: null,
      isOnDrag: false,
      onDragClientY: 0,
      positions: {
        home: { scrollTop: 0, params: {} },
      },
    };
  },
  computed: {
    thumbStyle() {
      return {
        transform: `translateY(${this.top}px)`,
        height: `${this.thumbHeight}px`,
      };
    },
    main() {
      return this.$parent.$refs.main;
    },
  },

  created() {
    this.$router.beforeEach((to, from, next) => {
      this.show = false;
      next();
    });
  },

  methods: {
    handleScroll() {
      const clintHeight = this.main.clientHeight - 128;
      const scrollHeight = this.main.scrollHeight - 128;
      const scrollTop = this.main.scrollTop;
      let top = ~~((scrollTop / scrollHeight) * clintHeight);
      let thumbHeight = ~~((clintHeight / scrollHeight) * clintHeight);

      if (thumbHeight < 24) thumbHeight = 24;
      if (top > clintHeight - thumbHeight) {
        top = clintHeight - thumbHeight;
      }
      this.top = top;
      this.thumbHeight = thumbHeight;

      if (!this.show && clintHeight !== thumbHeight) this.show = true;
      this.setScrollbarHideTimeout();

      const route = this.$route;
      if (route.meta.savePosition) {
        this.positions[route.name] = { scrollTop, params: route.params };
      }
    },
    handleMouseenter() {
      this.active = true;
    },
    handleMouseleave() {
      this.active = false;
      this.setScrollbarHideTimeout();
    },
    handleDragStart(e) {
      this.onDragClientY = e.clientY;
      this.isOnDrag = true;
      this.$parent.userSelectNone = true;
      document.addEventListener('mousemove', this.handleDragMove);
      document.addEventListener('mouseup', this.handleDragEnd);
    },
    handleDragMove(e) {
      if (!this.isOnDrag) return;
      const clintHeight = this.main.clientHeight - 128;
      const scrollHeight = this.main.scrollHeight - 128;
      const clientY = e.clientY;
      const scrollTop = this.main.scrollTop;
      const offset = ~~(
        ((clientY - this.onDragClientY) / clintHeight) *
        scrollHeight
      );
      this.top = ~~((scrollTop / scrollHeight) * clintHeight);
      this.main.scrollBy(0, offset);
      this.onDragClientY = clientY;
    },
    handleDragEnd() {
      this.isOnDrag = false;
      this.$parent.userSelectNone = false;
      document.removeEventListener('mousemove', this.handleDragMove);
      document.removeEventListener('mouseup', this.handleDragEnd);
    },
    handleClick(e) {
      let scrollTop;
      if (e.clientY < this.top + 84) {
        scrollTop = -256;
      } else {
        scrollTop = 256;
      }
      this.main.scrollBy({
        top: scrollTop,
        behavior: 'smooth',
      });
    },
    setScrollbarHideTimeout() {
      if (this.hideTimer !== null) clearTimeout(this.hideTimer);
      this.hideTimer = setTimeout(() => {
        if (!this.active) this.show = false;
        this.hideTimer = null;
      }, 4000);
    },
    restorePosition() {
      const route = this.$route;
      if (
        !route.meta.savePosition ||
        this.positions[route.name] === undefined ||
        this.main === undefined
      ) {
        return;
      }
      this.main.scrollTo({ top: this.positions[route.name].scrollTop });
    },
  },
};
</script>

<style lang="scss" scoped>
#scrollbar {
  position: fixed;
  right: 0;
  top: 0;
  bottom: 0;
  width: 16px;
  z-index: 1000;

  #thumbContainer {
    margin-top: 64px;
    div {
      transition: background 0.4s;
      position: absolute;
      right: 2px;
      width: 8px;
      height: 100%;
      border-radius: 4px;
      background: rgba(128, 128, 128, 0.38);
    }
  }
  #thumbContainer.active div {
    background: rgba(128, 128, 128, 0.58);
  }
}

[data-theme='dark'] {
  #thumbContainer div {
    background: var(--color-secondary-bg);
  }
}

#scrollbar.on-drag {
  left: 0;
  width: auto;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
</style>


================================================
FILE: src/components/SvgIcon.vue
================================================
<template>
  <svg :class="svgClass" aria-hidden="true">
    <use :xlink:href="iconName" />
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon',
  props: {
    iconClass: {
      type: String,
      required: true,
    },
    className: {
      type: String,
      default: '',
    },
  },
  computed: {
    iconName() {
      return `#icon-${this.iconClass}`;
    },
    svgClass() {
      if (this.className) {
        return 'svg-icon ' + this.className;
      } else {
        return 'svg-icon';
      }
    },
  },
};
</script>

<style scoped>
.svg-icon {
  fill: currentColor;
}
</style>


================================================
FILE: src/components/Toast.vue
================================================
<template>
  <transition name="fade">
    <div v-show="toast.show" class="toast">{{ toast.text }}</div>
  </transition>
</template>

<script>
import { mapState } from 'vuex';

export default {
  name: 'Toast',
  computed: {
    ...mapState(['toast']),
  },
};
</script>

<style lang="scss" scoped>
.toast {
  position: fixed;
  bottom: 64px;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 14px;
  color: var(--color-text);
  background: rgba(255, 255, 255, 0.88);
  box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.08);
  border: 1px solid rgba(0, 0, 0, 0.06);
  backdrop-filter: blur(12px);
  border-radius: 8px;
  box-sizing: border-box;
  padding: 6px 12px;
  z-index: 1010;
}

[data-theme='dark'] {
  .toast {
    background: rgba(46, 46, 46, 0.68);
    backdrop-filter: blur(16px) contrast(120%);
    border: 1px solid rgba(255, 255, 255, 0.08);
  }
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
}
</style>


================================================
FILE: src/components/TrackList.vue
================================================
<template>
  <div class="track-list">
    <ContextMenu ref="menu">
      <div v-show="type !== 'cloudDisk'" class="item-info">
        <img
          :src="rightClickedTrackComputed.al.picUrl | resizeImage(224)"
          loading="lazy"
        />
        <div class="info">
          <div class="title">{{ rightClickedTrackComputed.name }}</div>
          <div class="subtitle">{{ rightClickedTrackComputed.ar[0].name }}</div>
        </div>
      </div>
      <hr v-show="type !== 'cloudDisk'" />
      <div class="item" @click="play">{{ $t('contextMenu.play') }}</div>
      <div class="item" @click="addToQueue">{{
        $t('contextMenu.addToQueue')
      }}</div>
      <div
        v-if="extraContextMenuItem.includes('removeTrackFromQueue')"
        class="item"
        @click="removeTrackFromQueue"
        >从队列删除</div
      >
      <hr v-show="type !== 'cloudDisk'" />
      <div
        v-show="!isRightClickedTrackLiked && type !== 'cloudDisk'"
        class="item"
        @click="like"
      >
        {{ $t('contextMenu.saveToMyLikedSongs') }}
      </div>
      <div
        v-show="isRightClickedTrackLiked && type !== 'cloudDisk'"
        class="item"
        @click="like"
      >
        {{ $t('contextMenu.removeFromMyLikedSongs') }}
      </div>
      <div
        v-if="extraContextMenuItem.includes('removeTrackFromPlaylist')"
        class="item"
        @click="removeTrackFromPlaylist"
        >从歌单中删除</div
      >
      <div
        v-show="type !== 'cloudDisk'"
        class="item"
        @click="addTrackToPlaylist"
        >{{ $t('contextMenu.addToPlaylist') }}</div
      >
      <div v-show="type !== 'cloudDisk'" class="item" @click="copyLink">{{
        $t('contextMenu.copyUrl')
      }}</div>
      <div
        v-if="extraContextMenuItem.includes('removeTrackFromCloudDisk')"
        class="item"
        @click="removeTrackFromCloudDisk"
        >从云盘中删除</div
      >
    </ContextMenu>

    <div :style="listStyles">
      <TrackListItem
        v-for="(track, index) in tracks"
        :key="itemKey === 'id' ? track.id : `${track.id}${index}`"
        :track-prop="track"
        :track-no="index + 1"
        :highlight-playing-track="highlightPlayingTrack"
        @dblclick.native="playThisList(track.id || track.songId)"
        @click.right.native="openMenu($event, track, index)"
      />
    </div>
  </div>
</template>

<script>
import { mapActions, mapMutations, mapState } from 'vuex';
import { addOrRemoveTrackFromPlaylist } from '@/api/playlist';
import { cloudDiskTrackDelete } from '@/api/user';
import { isAccountLoggedIn } from '@/utils/auth';

import TrackListItem from '@/components/TrackListItem.vue';
import ContextMenu from '@/components/ContextMenu.vue';
import locale from '@/locale';

export default {
  name: 'TrackList',
  components: {
    TrackListItem,
    ContextMenu,
  },
  props: {
    tracks: {
      type: Array,
      default: () => {
        return [];
      },
    },
    type: {
      type: String,
      default: 'tracklist',
    }, // tracklist | album | playlist | cloudDisk
    id: {
      type: Number,
      default: 0,
    },
    dbclickTrackFunc: {
      type: String,
      default: 'default',
    },
    albumObject: {
      type: Object,
      default: () => {
        return {
          artist: {
            name: '',
          },
        };
      },
    },
    extraContextMenuItem: {
      type: Array,
      default: () => {
        return [
          // 'removeTrackFromPlaylist'
          // 'removeTrackFromQueue'
          // 'removeTrackFromCloudDisk'
        ];
      },
    },
    columnNumber: {
      type: Number,
      default: 4,
    },
    highlightPlayingTrack: {
      type: Boolean,
      default: true,
    },
    itemKey: {
      type: String,
      default: 'id',
    },
  },
  data() {
    return {
      rightClickedTrack: {
        id: 0,
        name: '',
        ar: [{ name: '' }],
        al: { picUrl: '' },
      },
      rightClickedTrackIndex: -1,
      listStyles: {},
    };
  },
  computed: {
    ...mapState(['liked', 'player']),
    isRightClickedTrackLiked() {
      return this.liked.songs.includes(this.rightClickedTrack?.id);
    },
    rightClickedTrackComputed() {
      return this.type === 'cloudDisk'
        ? {
            id: 0,
            name: '',
            ar: [{ name: '' }],
            al: { picUrl: '' },
          }
        : this.right
Download .txt
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
Download .txt
SYMBOL INDEX (244 symbols across 31 files)

FILE: src/api/album.js
  function getAlbum (line 10) | function getAlbum(id) {
  function newAlbums (line 42) | function newAlbums(params) {
  function albumDynamicDetail (line 56) | function albumDynamicDetail(id) {
  function likeAAlbum (line 73) | function likeAAlbum(params) {

FILE: src/api/artist.js
  function getArtist (line 11) | function getArtist(id) {
  function getArtistAlbum (line 42) | function getArtistAlbum(params) {
  function toplistOfArtists (line 60) | function toplistOfArtists(type = null) {
  function artistMv (line 78) | function artistMv(params) {
  function followAArtist (line 95) | function followAArtist(params) {
  function similarArtists (line 109) | function similarArtists(id) {

FILE: src/api/auth.js
  function loginWithPhone (line 15) | function loginWithPhone(params) {
  function loginWithEmail (line 33) | function loginWithEmail(params) {
  function loginQrCodeKey (line 44) | function loginQrCodeKey() {
  function loginQrCodeCreate (line 62) | function loginQrCodeCreate(params) {
  function loginQrCodeCheck (line 78) | function loginQrCodeCheck(key) {
  function refreshCookie (line 94) | function refreshCookie() {
  function logout (line 105) | function logout() {

FILE: src/api/lastfm.js
  function auth (line 24) | function auth() {
  function authGetSession (line 31) | function authGetSession(token) {
  function trackUpdateNowPlaying (line 48) | function trackUpdateNowPlaying(params) {
  function trackScrobble (line 65) | function trackScrobble(params) {

FILE: src/api/mv.js
  function mvDetail (line 10) | function mvDetail(mvid) {
  function mvUrl (line 31) | function mvUrl(params) {
  function simiMv (line 44) | function simiMv(mvid) {
  function likeAMV (line 62) | function likeAMV(params) {

FILE: src/api/others.js
  function search (line 19) | function search(params) {
  function personalFM (line 31) | function personalFM() {
  function fmTrash (line 41) | function fmTrash(id) {

FILE: src/api/playlist.js
  function recommendPlaylist (line 12) | function recommendPlaylist(params) {
  function dailyRecommendPlaylist (line 25) | function dailyRecommendPlaylist(params) {
  function getPlaylistDetail (line 45) | function getPlaylistDetail(id, noCache = false) {
  function highQualityPlaylist (line 73) | function highQualityPlaylist(params) {
  function topPlaylist (line 92) | function topPlaylist(params) {
  function playlistCatlist (line 104) | function playlistCatlist() {
  function toplists (line 115) | function toplists() {
  function subscribePlaylist (line 131) | function subscribePlaylist(params) {
  function deletePlaylist (line 146) | function deletePlaylist(id) {
  function createPlaylist (line 165) | function createPlaylist(params) {
  function addOrRemoveTrackFromPlaylist (line 183) | function addOrRemoveTrackFromPlaylist(params) {
  function dailyRecommendTracks (line 199) | function dailyRecommendTracks() {
  function intelligencePlaylist (line 223) | function intelligencePlaylist(params) {

FILE: src/api/track.js
  function getMP3 (line 17) | function getMP3(id) {
  function getTrackDetail (line 39) | function getTrackDetail(ids) {
  function getLyric (line 76) | function getLyric(id) {
  function topSong (line 102) | function topSong(type) {
  function likeATrack (line 121) | function likeATrack(params) {
  function scrobble (line 141) | function scrobble(params) {

FILE: src/api/user.js
  function userDetail (line 9) | function userDetail(uid) {
  function userAccount (line 24) | function userAccount() {
  function userPlaylist (line 45) | function userPlaylist(params) {
  function userPlayHistory (line 62) | function userPlayHistory(params) {
  function userLikedSongsIDs (line 76) | function userLikedSongsIDs(uid) {
  function dailySignin (line 93) | function dailySignin(type = 0) {
  function likedAlbums (line 113) | function likedAlbums(params) {
  function likedArtists (line 128) | function likedArtists(params) {
  function likedMVs (line 143) | function likedMVs(params) {
  function uploadSong (line 157) | function uploadSong(file) {
  function cloudDisk (line 185) | function cloudDisk(params = {}) {
  function cloudDiskTrackDetail (line 197) | function cloudDiskTrackDetail(id) {
  function cloudDiskTrackDelete (line 212) | function cloudDiskTrackDelete(id) {

FILE: src/background.js
  class Background (line 84) | class Background {
    method constructor (line 85) | constructor() {
    method init (line 101) | init() {
    method initDevtools (line 130) | async initDevtools() {
    method createExpressApp (line 152) | createExpressApp() {
    method createWindow (line 173) | createWindow() {
    method checkForUpdates (line 267) | checkForUpdates() {
    method handleWindowEvents (line 296) | handleWindowEvents() {
    method handleAppEvents (line 369) | handleAppEvents() {

FILE: src/electron/dockMenu.js
  function createDockMenu (line 3) | function createDockMenu(win) {

FILE: src/electron/globalShortcut.js
  function registerGlobalShortcut (line 9) | function registerGlobalShortcut(win, store) {

FILE: src/electron/ipcMain.js
  function toBuffer (line 82) | function toBuffer(data) {
  function getBiliVideoFile (line 96) | async function getBiliVideoFile(url) {
  function parseSourceStringToList (line 119) | function parseSourceStringToList(executor, sourceString) {
  function initIpcMain (line 136) | function initIpcMain(win, store, trayEventEmitter) {

FILE: src/electron/ipcRenderer.js
  function ipcRenderer (line 5) | function ipcRenderer(vueInstance) {

FILE: src/electron/menu.js
  function createMenu (line 8) | function createMenu(win, store) {

FILE: src/electron/mpris.js
  function createMpris (line 4) | function createMpris(window) {
  function createDbus (line 73) | async function createDbus(window) {

FILE: src/electron/services.js
  function startNeteaseMusicApi (line 5) | async function startNeteaseMusicApi() {

FILE: src/electron/touchBar.js
  function createTouchBar (line 5) | function createTouchBar(window) {

FILE: src/electron/tray.js
  function createMenuTemplate (line 6) | function createMenuTemplate(win) {
  class YPMTrayLinuxImpl (line 104) | class YPMTrayLinuxImpl {
    method constructor (line 105) | constructor(tray, win, emitter, store) {
    method initTemplate (line 118) | initTemplate() {
    method handleEvents (line 134) | handleEvents() {
    method updateIcon (line 155) | updateIcon() {
  class YPMTrayWindowsImpl (line 175) | class YPMTrayWindowsImpl {
    method constructor (line 176) | constructor(tray, win, emitter, store) {
    method handleEvents (line 193) | handleEvents() {
    method updateIcon (line 225) | updateIcon() {
  function createTray (line 245) | function createTray(win, eventEmitter, store) {

FILE: src/registerServiceWorker.js
  method ready (line 7) | ready() {
  method registered (line 13) | registered() {
  method cached (line 16) | cached() {
  method updatefound (line 19) | updatefound() {
  method updated (line 22) | updated() {
  method offline (line 25) | offline() {
  method error (line 30) | error(error) {

FILE: src/store/actions.js
  method showToast (line 18) | showToast({ state, commit }, text) {
  method likeATrack (line 35) | likeATrack({ state, commit, dispatch }, id) {

FILE: src/store/index.js
  method set (line 56) | set(target, prop, val) {

FILE: src/store/mutations.js
  method updateLikedXXX (line 5) | updateLikedXXX(state, { name, data }) {
  method changeLang (line 11) | changeLang(state, lang) {
  method changeMusicQuality (line 14) | changeMusicQuality(state, value) {
  method changeLyricFontSize (line 17) | changeLyricFontSize(state, value) {
  method changeOutputDevice (line 20) | changeOutputDevice(state, deviceId) {
  method updateSettings (line 23) | updateSettings(state, { key, value }) {
  method updateData (line 26) | updateData(state, { key, value }) {
  method togglePlaylistCategory (line 29) | togglePlaylistCategory(state, name) {
  method updateToast (line 40) | updateToast(state, toast) {
  method updateModal (line 43) | updateModal(state, { modalName, key, value }) {
  method toggleLyrics (line 52) | toggleLyrics(state) {
  method updateDailyTracks (line 55) | updateDailyTracks(state, dailyTracks) {
  method updateLastfm (line 58) | updateLastfm(state, session) {
  method updateShortcut (line 61) | updateShortcut(state, { id, type, shortcut }) {
  method restoreDefaultShortcuts (line 69) | restoreDefaultShortcuts(state) {
  method enableScrolling (line 72) | enableScrolling(state, status = null) {
  method updateTitle (line 75) | updateTitle(state, title) {

FILE: src/store/plugins/sendSettings.js
  function getSendSettingsPlugin (line 1) | function getSendSettingsPlugin() {

FILE: src/utils/Player.js
  constant PLAY_PAUSE_FADE_DURATION (line 15) | const PLAY_PAUSE_FADE_DURATION = 200;
  constant INDEX_IN_PLAY_NEXT (line 17) | const INDEX_IN_PLAY_NEXT = -1;
  constant UNPLAYABLE_CONDITION (line 23) | const UNPLAYABLE_CONDITION = {
  function setTitle (line 44) | function setTitle(track) {
  function setTrayLikeState (line 54) | function setTrayLikeState(isLiked) {
  method constructor (line 61) | constructor() {
  method repeatMode (line 109) | get repeatMode() {
  method repeatMode (line 112) | set repeatMode(mode) {
  method shuffle (line 120) | get shuffle() {
  method shuffle (line 123) | set shuffle(shuffle) {
  method reversed (line 136) | get reversed() {
  method reversed (line 139) | set reversed(reversed) {
  method volume (line 148) | get volume() {
  method volume (line 151) | set volume(volume) {
  method list (line 155) | get list() {
  method list (line 158) | set list(list) {
  method current (line 161) | get current() {
  method current (line 164) | set current(current) {
  method enabled (line 171) | get enabled() {
  method playing (line 174) | get playing() {
  method currentTrack (line 177) | get currentTrack() {
  method currentTrackID (line 180) | get currentTrackID() {
  method playlistSource (line 183) | get playlistSource() {
  method playNextList (line 186) | get playNextList() {
  method isPersonalFM (line 189) | get isPersonalFM() {
  method personalFMTrack (line 192) | get personalFMTrack() {
  method currentTrackDuration (line 195) | get currentTrackDuration() {
  method progress (line 200) | get progress() {
  method progress (line 203) | set progress(value) {
  method isCurrentTrackLiked (line 211) | get isCurrentTrackLiked() {
  method _init (line 215) | _init() {
  method _setPlaying (line 242) | _setPlaying(isPlaying) {
  method _setIntervals (line 248) | _setIntervals() {
  method _getNextTrack (line 261) | _getNextTrack() {
  method _getPrevTrack (line 283) | _getPrevTrack() {
  method _shuffleTheList (line 300) | async _shuffleTheList(firstTrackID = this.currentTrackID) {
  method _scrobble (line 306) | async _scrobble(track, time, completed = false) {
  method _playAudioSource (line 332) | _playAudioSource(source, autoplay = true) {
  method _getAudioSourceBlobURL (line 375) | _getAudioSourceBlobURL(data) {
  method _getAudioSourceFromCache (line 392) | _getAudioSourceFromCache(id) {
  method _getAudioSourceFromNetease (line 398) | _getAudioSourceFromNetease(track) {
  method _getAudioSourceFromUnblockMusic (line 416) | async _getAudioSourceFromUnblockMusic(track) {
  method _getAudioSource (line 484) | _getAudioSource(track) {
  method _replaceCurrentTrack (line 493) | _replaceCurrentTrack(
  method _replaceCurrentTrackAudio (line 516) | _replaceCurrentTrackAudio(
  method _cacheNextTrack (line 553) | _cacheNextTrack() {
  method _loadSelfFromLocalStorage (line 564) | _loadSelfFromLocalStorage() {
  method _initMediaSession (line 571) | _initMediaSession() {
  method _updateMediaSessionMetaData (line 602) | _updateMediaSessionMetaData(track) {
  method _updateMprisState (line 634) | async _updateMprisState(track, metadata) {
  method _updateMediaSessionPositionState (line 654) | _updateMediaSessionPositionState() {
  method _nextTrackCallback (line 666) | _nextTrackCallback() {
  method _loadPersonalFMNextTrack (line 674) | _loadPersonalFMNextTrack() {
  method _playDiscordPresence (line 696) | _playDiscordPresence(track, seekTime = 0) {
  method _pauseDiscordPresence (line 707) | _pauseDiscordPresence(track) {
  method _playNextTrack (line 716) | _playNextTrack(isPersonal) {
  method appendTrack (line 724) | appendTrack(trackID) {
  method playNextTrack (line 727) | playNextTrack() {
  method playNextFMTrack (line 744) | async playNextFMTrack() {
  method playPrevTrack (line 789) | playPrevTrack() {
  method saveSelfToLocalStorage (line 800) | saveSelfToLocalStorage() {
  method pause (line 810) | pause() {
  method play (line 820) | play() {
  method playOrPause (line 847) | playOrPause() {
  method seek (line 854) | seek(time = null, sendMpris = true) {
  method mute (line 865) | mute() {
  method setOutputDevice (line 873) | setOutputDevice() {
  method replacePlaylist (line 880) | replacePlaylist(
  method playAlbumByID (line 901) | playAlbumByID(id, trackID = 'first') {
  method playPlaylistByID (line 907) | playPlaylistByID(id, trackID = 'first', noCache = false) {
  method playArtistByID (line 916) | playArtistByID(id, trackID = 'first') {
  method playTrackOnListByID (line 922) | playTrackOnListByID(id, listName = 'default') {
  method playIntelligenceListById (line 928) | playIntelligenceListById(id, trackID = 'first', noCache = false) {
  method addTrackToPlayNext (line 940) | addTrackToPlayNext(trackID, playNow = false) {
  method playPersonalFM (line 946) | playPersonalFM() {
  method moveToFMTrash (line 954) | async moveToFMTrash() {
  method sendSelfToIpcMain (line 962) | sendSelfToIpcMain() {
  method switchRepeatMode (line 972) | switchRepeatMode() {
  method switchShuffle (line 984) | switchShuffle() {
  method switchReversed (line 990) | switchReversed() {
  method clearPlayNextList (line 994) | clearPlayNextList() {
  method removeTrackFromQueue (line 997) | removeTrackFromQueue(index) {

FILE: src/utils/auth.js
  function setCookies (line 5) | function setCookies(string) {
  function getCookie (line 14) | function getCookie(key) {
  function removeCookie (line 18) | function removeCookie(key) {
  function isLoggedIn (line 24) | function isLoggedIn() {
  function isAccountLoggedIn (line 29) | function isAccountLoggedIn() {
  function isUsernameLoggedIn (line 37) | function isUsernameLoggedIn() {
  function isLooseLoggedIn (line 42) | function isLooseLoggedIn() {
  function doLogout (line 46) | function doLogout() {

FILE: src/utils/common.js
  function isTrackPlayable (line 6) | function isTrackPlayable(track) {
  function mapTrackPlayableStatus (line 41) | function mapTrackPlayableStatus(tracks, privileges = []) {
  function randomNum (line 57) | function randomNum(minNum, maxNum) {
  function shuffleAList (line 68) | function shuffleAList(list) {
  function throttle (line 81) | function throttle(fn, time) {
  function updateHttps (line 93) | function updateHttps(url) {
  function dailyTask (line 98) | function dailyTask() {
  function changeAppearance (line 115) | function changeAppearance(appearance) {
  function splitSoundtrackAlbumTitle (line 127) | function splitSoundtrackAlbumTitle(title) {
  function splitAlbumTitle (line 172) | function splitAlbumTitle(title) {
  function bytesToSize (line 198) | function bytesToSize(bytes) {
  function formatTrackTime (line 215) | function formatTrackTime(value) {

FILE: src/utils/db.js
  function deleteExcessCache (line 33) | async function deleteExcessCache() {
  function cacheTrackSource (line 53) | function cacheTrackSource(trackInfo, url, bitRate, from = 'netease') {
  function getTrackSource (line 88) | function getTrackSource(id) {
  function cacheTrackDetail (line 98) | function cacheTrackDetail(track, privileges) {
  function getTrackDetailFromCache (line 107) | function getTrackDetailFromCache(ids) {
  function cacheLyric (line 127) | function cacheLyric(id, lyrics) {
  function getLyricFromCache (line 135) | function getLyricFromCache(id) {
  function cacheAlbum (line 142) | function cacheAlbum(id, album) {
  function getAlbumFromCache (line 150) | function getAlbumFromCache(id) {
  function countDBSize (line 157) | function countDBSize() {
  function clearDB (line 176) | function clearDB() {

FILE: src/utils/lyrics.js
  function lyricParser (line 1) | function lyricParser(lrc) {
  function parseLyric (line 28) | function parseLyric(lrc) {
  function trimContent (line 84) | function trimContent(content) {
  function copyLyric (line 92) | async function copyLyric(lyric) {

FILE: src/utils/playList.js
  function hasListSource (line 10) | function hasListSource() {
  function goToListSource (line 14) | function goToListSource() {
  function getListSourcePath (line 18) | function getListSourcePath() {
  function getRecommendPlayList (line 30) | async function getRecommendPlayList(limit, removePrivateRecommand) {
  function replaceRecommendResult (line 48) | async function replaceRecommendResult(recommend) {

FILE: vue.config.js
  function resolve (line 3) | function resolve(dir) {
  method chainWebpack (line 45) | chainWebpack(config) {
Condensed preview — 126 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (613K chars).
[
  {
    "path": ".dockerignore",
    "chars": 157,
    "preview": "node_modules\nnpm-debug.log\nDockerfile*\ndocker-compose*\n.dockerignore\n.git\n.github\n.gitignore\nREADME.md\nLICENSE\n.vscode\nd"
  },
  {
    "path": ".editorconfig",
    "chars": 131,
    "preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\ncharset = utf-8\ntrim_trailing_whitespace = false\ninsert_final_newl"
  },
  {
    "path": ".envrc",
    "chars": 209,
    "preview": "source_url \"https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc\" \"sha256-7"
  },
  {
    "path": ".gitattributes",
    "chars": 149,
    "preview": "* text eol=lf\n# Denote all files that are truly binary and should not be modified.\n*.png binary\n*.jpg binary\n*.mp3 binar"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/----------.md",
    "chars": 385,
    "preview": "---\nname: 反馈问题或请求新功能\nabout: bug & feature\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n# 尽量每个 issue 只提一个 bug 或新功能\n\n### 提新 is"
  },
  {
    "path": ".github/workflows/build.yaml",
    "chars": 3882,
    "preview": "name: Release\n\nenv:\n  YARN_INSTALL_NOPT: yarn add --ignore-platform --ignore-optional\n\non:\n  push:\n    branches:\n      -"
  },
  {
    "path": ".github/workflows/sync.yml",
    "chars": 1385,
    "preview": "name: Upstream Sync\n\npermissions:\n  contents: write\n  issues: write\n  actions: write\n\non:\n  schedule:\n    - cron: '0 * *"
  },
  {
    "path": ".gitignore",
    "chars": 481,
    "preview": ".DS_Store\nnode_modules\n/dist\n\n# local env files\n.env\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*"
  },
  {
    "path": ".nvmrc",
    "chars": 2,
    "preview": "14"
  },
  {
    "path": ".prettierignore",
    "chars": 20,
    "preview": "build\ncoverage\ndist\n"
  },
  {
    "path": ".prettierrc",
    "chars": 245,
    "preview": "{\n  \"trailingComma\": \"es5\",\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"jsxSingleQuot"
  },
  {
    "path": "Dockerfile",
    "chars": 1221,
    "preview": "FROM node:16.13.1-alpine AS build\nENV VUE_APP_NETEASE_API_URL=/api\nWORKDIR /app\nRUN sed -i 's/dl-cdn.alpinelinux.org/mir"
  },
  {
    "path": "LICENSE",
    "chars": 1069,
    "preview": "MIT License\n\nCopyright (c) 2020-2023 qier222\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
  },
  {
    "path": "README.md",
    "chars": 7381,
    "preview": "<div align=\"center\">\n\t<a href=\"http://go.warp.dev/YesPlayMusic\" target=\"_blank\">\n\t\t<sup>Special thanks to:</sup>\n\t\t<br>\n"
  },
  {
    "path": "babel.config.js",
    "chars": 170,
    "preview": "module.exports = {\n  presets: [\n    [\n      '@vue/cli-plugin-babel/preset',\n      {\n        useBuiltIns: 'usage',\n      "
  },
  {
    "path": "devenv.nix",
    "chars": 1274,
    "preview": "{ pkgs, lib, config, inputs, ... }:\n\nlet\n  nodejs16 = import inputs.nodejs16 { system = pkgs.stdenv.system; };\nin\n{\n  # "
  },
  {
    "path": "devenv.yaml",
    "chars": 577,
    "preview": "# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json\ninputs:\n  nixpkgs:\n    url: github:nixos/nixpkgs/ni"
  },
  {
    "path": "docker/nginx.conf.example",
    "chars": 709,
    "preview": "server {\n  gzip on;\n  listen       80;\n  listen  [::]:80;\n  server_name  localhost;\n\n  location / {\n    root      /usr/s"
  },
  {
    "path": "docker-compose.yml",
    "chars": 984,
    "preview": "services:\n  YesPlayMusic:\n    build:\n      context: .\n    image: yesplaymusic\n    container_name: YesPlayMusic\n    volum"
  },
  {
    "path": "install-replit.sh",
    "chars": 889,
    "preview": " #!/usr/bin/bash\n\n# 初始化 .replit 和 replit.nix\nif [[ $1 == i ]];then\n    echo -e 'run = [\"bash\", \"main.sh\"]\\n\\nentrypoint "
  },
  {
    "path": "jsconfig.json",
    "chars": 281,
    "preview": "{\n  // 支持 @ 的别名解析\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    },\n    \"target\": "
  },
  {
    "path": "package.json",
    "chars": 3994,
    "preview": "{\n  \"name\": \"yesplaymusic\",\n  \"version\": \"0.4.10\",\n  \"private\": true,\n  \"description\": \"A third party music player for N"
  },
  {
    "path": "public/index.html",
    "chars": 668,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"utf-8\" />\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edg"
  },
  {
    "path": "public/robots.txt",
    "chars": 26,
    "preview": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "restyled.yml",
    "chars": 145,
    "preview": "commit_template: 'style: with ${restyler.name}'\nrestylers:\n  - prettier\n  - prettier-json\n  - prettier-markdown\n  - pret"
  },
  {
    "path": "src/App.vue",
    "chars": 3738,
    "preview": "<template>\n  <div id=\"app\" :class=\"{ 'user-select-none': userSelectNone }\">\n    <Scrollbar v-show=\"!showLyrics\" ref=\"scr"
  },
  {
    "path": "src/api/album.js",
    "chars": 1630,
    "preview": "import request from '@/utils/request';\nimport { mapTrackPlayableStatus } from '@/utils/common';\nimport { cacheAlbum, get"
  },
  {
    "path": "src/api/artist.js",
    "chars": 2367,
    "preview": "import request from '@/utils/request';\nimport { mapTrackPlayableStatus } from '@/utils/common';\nimport { isAccountLogged"
  },
  {
    "path": "src/api/auth.js",
    "chars": 2066,
    "preview": "import request from '@/utils/request';\n\n/**\n * 手机登录\n * - phone: 手机号码\n * - password: 密码\n * - countrycode: 国家码,用于国外手机号登录,例"
  },
  {
    "path": "src/api/lastfm.js",
    "chars": 2010,
    "preview": "// Last.fm API documents 👉 https://www.last.fm/api\n\nimport axios from 'axios';\nimport md5 from 'crypto-js/md5';\n\nconst a"
  },
  {
    "path": "src/api/mv.js",
    "chars": 1356,
    "preview": "import request from '@/utils/request';\n\n/**\n * 获取 mv 数据\n * 说明 : 调用此接口 , 传入 mvid ( 在搜索音乐的时候传 type=1004 获得 ) , 可获取对应 MV 数据"
  },
  {
    "path": "src/api/others.js",
    "chars": 1334,
    "preview": "import request from '@/utils/request';\nimport { mapTrackPlayableStatus } from '@/utils/common';\n\n/**\n * 搜索\n * 说明 : 调用此接口"
  },
  {
    "path": "src/api/playlist.js",
    "chars": 5007,
    "preview": "import request from '@/utils/request';\nimport { mapTrackPlayableStatus } from '@/utils/common';\n\n/**\n * 推荐歌单\n * 说明 : 调用此"
  },
  {
    "path": "src/api/track.js",
    "chars": 3193,
    "preview": "import store from '@/store';\nimport request from '@/utils/request';\nimport { mapTrackPlayableStatus } from '@/utils/comm"
  },
  {
    "path": "src/api/user.js",
    "chars": 4114,
    "preview": "import request from '@/utils/request';\n\n/**\n * 获取用户详情\n * 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情\n * - uid : 用户 id\n * @param {n"
  },
  {
    "path": "src/assets/css/global.scss",
    "chars": 3208,
    "preview": "@font-face {\n  font-family: 'Barlow';\n  font-weight: normal;\n  src: url('~@/assets/fonts/Barlow-Regular.woff2') format('"
  },
  {
    "path": "src/assets/css/nprogress.css",
    "chars": 694,
    "preview": "/* Make clicks pass-through */\n#nprogress {\n  pointer-events: none;\n}\n\n#nprogress .bar {\n  background: #335eea;\n\n  posit"
  },
  {
    "path": "src/assets/css/plyr.css",
    "chars": 44426,
    "preview": "@keyframes plyr-progress {\n  to {\n    background-position: 25px 0;\n    background-position: var(--plyr-progress-loading-"
  },
  {
    "path": "src/assets/css/slider.css",
    "chars": 2858,
    "preview": "/* rail style */\n.vue-slider-rail {\n  background-color: rgba(128, 128, 128, 0.18);\n  border-radius: 15px;\n}\n\n/* process "
  },
  {
    "path": "src/assets/icons/index.js",
    "chars": 252,
    "preview": "import Vue from 'vue';\nimport SvgIcon from '@/components/SvgIcon';\n\nVue.component('svg-icon', SvgIcon);\nconst requireAll"
  },
  {
    "path": "src/background.js",
    "chars": 13207,
    "preview": "'use strict';\nimport {\n  app,\n  protocol,\n  BrowserWindow,\n  shell,\n  dialog,\n  globalShortcut,\n  nativeTheme,\n  screen,"
  },
  {
    "path": "src/components/ArtistsInLine.vue",
    "chars": 1085,
    "preview": "<template>\n  <span class=\"artist-in-line\">\n    {{ computedPrefix }}\n    <span v-for=\"(ar, index) in filteredArtists\" :ke"
  },
  {
    "path": "src/components/ButtonIcon.vue",
    "chars": 598,
    "preview": "<template>\n  <button class=\"button-icon\"><slot></slot></button>\n</template>\n\n<script>\nexport default {\n  name: 'ButtonIc"
  },
  {
    "path": "src/components/ButtonTwoTone.vue",
    "chars": 1895,
    "preview": "<template>\n  <button :style=\"buttonStyle\" :class=\"color\">\n    <svg-icon\n      v-if=\"iconClass !== null\"\n      :icon-clas"
  },
  {
    "path": "src/components/ContextMenu.vue",
    "chars": 3707,
    "preview": "<template>\n  <div ref=\"contextMenu\" class=\"context-menu\">\n    <div\n      v-if=\"showMenu\"\n      ref=\"menu\"\n      class=\"m"
  },
  {
    "path": "src/components/Cover.vue",
    "chars": 3903,
    "preview": "<template>\n  <div\n    class=\"cover\"\n    :class=\"{ 'cover-hover': coverHover }\"\n    @mouseover=\"focus = true\"\n    @mousel"
  },
  {
    "path": "src/components/CoverRow.vue",
    "chars": 5277,
    "preview": "<template>\n  <div class=\"cover-row\" :style=\"rowStyles\">\n    <div\n      v-for=\"item in items\"\n      :key=\"item.id\"\n      "
  },
  {
    "path": "src/components/DailyTracksCard.vue",
    "chars": 3708,
    "preview": "<template>\n  <div class=\"daily-recommend-card\" @click=\"goToDailyTracks\">\n    <img :src=\"coverUrl\" loading=\"lazy\" />\n    "
  },
  {
    "path": "src/components/ExplicitSymbol.vue",
    "chars": 561,
    "preview": "<template>\n  <svg-icon icon-class=\"explicit\" :style=\"svgStyle\"></svg-icon>\n</template>\n\n<script>\nimport SvgIcon from '@/"
  },
  {
    "path": "src/components/FMCard.vue",
    "chars": 4685,
    "preview": "<template>\n  <div class=\"fm\" :style=\"{ background }\" data-theme=\"dark\">\n    <img :src=\"nextTrackCover\" style=\"display: n"
  },
  {
    "path": "src/components/LinuxTitlebar.vue",
    "chars": 2751,
    "preview": "<template>\n  <div class=\"linux-titlebar\">\n    <div class=\"logo\">\n      <img src=\"img/logos/yesplaymusic-white24x24.png\" "
  },
  {
    "path": "src/components/Modal.vue",
    "chars": 3739,
    "preview": "<template>\n  <div v-show=\"show\" class=\"shade\" @click=\"clickOutside\">\n    <div class=\"modal\" :style=\"modalStyles\" @click."
  },
  {
    "path": "src/components/ModalAddTrackToPlaylist.vue",
    "chars": 4036,
    "preview": "<template>\n  <Modal\n    class=\"add-track-to-playlist-modal\"\n    :show=\"show\"\n    :close=\"close\"\n    :show-footer=\"false\""
  },
  {
    "path": "src/components/ModalNewPlaylist.vue",
    "chars": 3752,
    "preview": "<template>\n  <Modal\n    class=\"add-playlist-modal\"\n    :show=\"show\"\n    :close=\"close\"\n    title=\"新建歌单\"\n    width=\"25vw\""
  },
  {
    "path": "src/components/MvRow.vue",
    "chars": 3739,
    "preview": "<template>\n  <div class=\"mv-row\" :class=\"{ 'without-padding': withoutPadding }\">\n    <div v-for=\"mv in mvs\" :key=\"getID("
  },
  {
    "path": "src/components/Navbar.vue",
    "chars": 7828,
    "preview": "<template>\n  <div>\n    <nav :class=\"{ 'has-custom-titlebar': hasCustomTitlebar }\">\n      <Win32Titlebar v-if=\"enableWin3"
  },
  {
    "path": "src/components/Player.vue",
    "chars": 12359,
    "preview": "<template>\n  <div class=\"player\" @click=\"handleClick\" @mousedown=\"handleMouseDown\">\n    <div\n      class=\"progress-bar\"\n"
  },
  {
    "path": "src/components/Scrollbar.vue",
    "chars": 4668,
    "preview": "<template>\n  <div>\n    <transition name=\"fade\">\n      <div\n        v-show=\"show\"\n        id=\"scrollbar\"\n        :class=\""
  },
  {
    "path": "src/components/SvgIcon.vue",
    "chars": 607,
    "preview": "<template>\n  <svg :class=\"svgClass\" aria-hidden=\"true\">\n    <use :xlink:href=\"iconName\" />\n  </svg>\n</template>\n\n<script"
  },
  {
    "path": "src/components/Toast.vue",
    "chars": 1039,
    "preview": "<template>\n  <transition name=\"fade\">\n    <div v-show=\"toast.show\" class=\"toast\">{{ toast.text }}</div>\n  </transition>\n"
  },
  {
    "path": "src/components/TrackList.vue",
    "chars": 8920,
    "preview": "<template>\n  <div class=\"track-list\">\n    <ContextMenu ref=\"menu\">\n      <div v-show=\"type !== 'cloudDisk'\" class=\"item-"
  },
  {
    "path": "src/components/TrackListItem.vue",
    "chars": 9911,
    "preview": "<template>\n  <div\n    class=\"track\"\n    :class=\"trackClass\"\n    :style=\"trackStyle\"\n    :title=\"showUnavailableSongInGre"
  },
  {
    "path": "src/components/Win32Titlebar.vue",
    "chars": 2581,
    "preview": "<template>\n  <div class=\"win32-titlebar\">\n    <div class=\"title\">{{ title }}</div>\n    <div class=\"controls\">\n      <div"
  },
  {
    "path": "src/electron/dockMenu.js",
    "chars": 445,
    "preview": "const { Menu } = require('electron');\n\nexport function createDockMenu(win) {\n  return Menu.buildFromTemplate([\n    {\n   "
  },
  {
    "path": "src/electron/globalShortcut.js",
    "chars": 1503,
    "preview": "import defaultShortcuts from '@/utils/shortcuts';\nconst { globalShortcut } = require('electron');\n\nconst clc = require('"
  },
  {
    "path": "src/electron/ipcMain.js",
    "chars": 9023,
    "preview": "import { app, dialog, globalShortcut, ipcMain } from 'electron';\nimport UNM from '@unblockneteasemusic/rust-napi';\nimpor"
  },
  {
    "path": "src/electron/ipcRenderer.js",
    "chars": 2249,
    "preview": "import store from '@/store';\n\nconst player = store.state.player;\n\nexport function ipcRenderer(vueInstance) {\n  const sel"
  },
  {
    "path": "src/electron/menu.js",
    "chars": 5903,
    "preview": "import defaultShortcuts from '@/utils/shortcuts';\nconst { app, Menu } = require('electron');\n// import { autoUpdater } f"
  },
  {
    "path": "src/electron/mpris.js",
    "chars": 2780,
    "preview": "import dbus from 'dbus-next';\nimport { ipcMain, app } from 'electron';\n\nexport function createMpris(window) {\n  const Pl"
  },
  {
    "path": "src/electron/services.js",
    "chars": 420,
    "preview": "import clc from 'cli-color';\nimport checkAuthToken from '../utils/checkAuthToken';\nimport server from '@neteaseapireborn"
  },
  {
    "path": "src/electron/touchBar.js",
    "chars": 2465,
    "preview": "const { TouchBar, nativeImage, ipcMain } = require('electron');\nconst { TouchBarButton, TouchBarSpacer } = TouchBar;\ncon"
  },
  {
    "path": "src/electron/tray.js",
    "chars": 6846,
    "preview": "/* global __static */\nimport path from 'path';\nimport { app, nativeImage, Tray, Menu, nativeTheme } from 'electron';\nimp"
  },
  {
    "path": "src/locale/index.js",
    "chars": 499,
    "preview": "import Vue from 'vue';\nimport VueClipboard from 'vue-clipboard2';\nimport VueI18n from 'vue-i18n';\nimport store from '@/s"
  },
  {
    "path": "src/locale/lang/en.js",
    "chars": 7813,
    "preview": "export default {\n  common: {\n    play: 'PLAY',\n    songs: 'Songs',\n  },\n  nav: {\n    home: 'Home',\n    explore: 'Explore"
  },
  {
    "path": "src/locale/lang/tr.js",
    "chars": 7315,
    "preview": "export default {\n  common: {\n    play: 'OYNAT',\n    songs: 'Müzikler',\n  },\n  nav: {\n    home: 'Anasayfa',\n    explore: "
  },
  {
    "path": "src/locale/lang/zh-CN.js",
    "chars": 5746,
    "preview": "export default {\n  common: {\n    play: '播放',\n    songs: '首歌',\n  },\n  nav: {\n    home: '首页',\n    explore: '发现',\n    libra"
  },
  {
    "path": "src/locale/lang/zh-TW.js",
    "chars": 5730,
    "preview": "export default {\n  common: {\n    play: '播放',\n    songs: '首歌',\n  },\n  nav: {\n    home: '首頁',\n    explore: '發現',\n    libra"
  },
  {
    "path": "src/main.js",
    "chars": 1175,
    "preview": "import Vue from 'vue';\nimport VueGtag from 'vue-gtag';\nimport App from './App.vue';\nimport router from './router';\nimpor"
  },
  {
    "path": "src/ncmModDef.js",
    "chars": 16933,
    "preview": "module.exports = [\n  {\n    identifier: 'user_update',\n    route: '/user/update',\n    module: require('@neteaseapireborn/"
  },
  {
    "path": "src/registerServiceWorker.js",
    "chars": 944,
    "preview": "/* eslint-disable no-console */\n\nimport { register } from 'register-service-worker';\n\nif (!process.env.IS_ELECTRON) {\n  "
  },
  {
    "path": "src/router/index.js",
    "chars": 3542,
    "preview": "import Vue from 'vue';\nimport VueRouter from 'vue-router';\nimport { isLooseLoggedIn, isAccountLoggedIn } from '@/utils/a"
  },
  {
    "path": "src/store/actions.js",
    "chars": 5518,
    "preview": "// import store, { state, dispatch, commit } from \"@/store\";\nimport { isAccountLoggedIn, isLooseLoggedIn } from '@/utils"
  },
  {
    "path": "src/store/index.js",
    "chars": 1718,
    "preview": "import Vue from 'vue';\nimport Vuex from 'vuex';\nimport state from './state';\nimport mutations from './mutations';\nimport"
  },
  {
    "path": "src/store/initLocalStorage.js",
    "chars": 1379,
    "preview": "import { playlistCategories } from '@/utils/staticData';\nimport shortcuts from '@/utils/shortcuts';\n\nconsole.debug('[deb"
  },
  {
    "path": "src/store/mutations.js",
    "chars": 2251,
    "preview": "import shortcuts from '@/utils/shortcuts';\nimport cloneDeep from 'lodash/cloneDeep';\n\nexport default {\n  updateLikedXXX("
  },
  {
    "path": "src/store/plugins/localStorage.js",
    "chars": 238,
    "preview": "export default store => {\n  store.subscribe((mutation, state) => {\n    // console.log(mutation);\n    localStorage.setIte"
  },
  {
    "path": "src/store/plugins/sendSettings.js",
    "chars": 349,
    "preview": "export function getSendSettingsPlugin() {\n  const electron = window.require('electron');\n  const ipcRenderer = electron."
  },
  {
    "path": "src/store/state.js",
    "chars": 1261,
    "preview": "import initLocalStorage from './initLocalStorage';\nimport pkg from '../../package.json';\nimport updateApp from '@/utils/"
  },
  {
    "path": "src/utils/Player.js",
    "chars": 28640,
    "preview": "import { getAlbum } from '@/api/album';\nimport { getArtist } from '@/api/artist';\nimport { trackScrobble, trackUpdateNow"
  },
  {
    "path": "src/utils/auth.js",
    "chars": 1426,
    "preview": "import Cookies from 'js-cookie';\nimport { logout } from '@/api/auth';\nimport store from '@/store';\n\nexport function setC"
  },
  {
    "path": "src/utils/base64.js",
    "chars": 1849,
    "preview": "// https://github.com/niklasvh/base64-arraybuffer/blob/master/src/index.ts\n// Copyright (c) 2012 Niklas von Hertzen Lice"
  },
  {
    "path": "src/utils/checkAuthToken.js",
    "chars": 354,
    "preview": "import os from 'os';\nimport fs from 'fs';\nimport path from 'path';\n\n// extract from NeteasyCloudMusicAPI/generateConfig."
  },
  {
    "path": "src/utils/common.js",
    "chars": 6073,
    "preview": "import { isAccountLoggedIn } from './auth';\nimport { refreshCookie } from '@/api/auth';\nimport dayjs from 'dayjs';\nimpor"
  },
  {
    "path": "src/utils/db.js",
    "chars": 4489,
    "preview": "import axios from 'axios';\nimport Dexie from 'dexie';\nimport store from '@/store';\n// import pkg from \"../../package.jso"
  },
  {
    "path": "src/utils/filters.js",
    "chars": 3195,
    "preview": "import Vue from 'vue';\nimport dayjs from 'dayjs';\nimport duration from 'dayjs/plugin/duration';\nimport relativeTime from"
  },
  {
    "path": "src/utils/lyrics.js",
    "chars": 2907,
    "preview": "export function lyricParser(lrc) {\n  return {\n    lyric: parseLyric(lrc?.lrc?.lyric || ''),\n    tlyric: parseLyric(lrc?."
  },
  {
    "path": "src/utils/nativeAlert.js",
    "chars": 931,
    "preview": "/**\n * Returns an alert-like function that fits current runtime environment\n *\n * This function is amid to solve a elect"
  },
  {
    "path": "src/utils/platform.js",
    "chars": 335,
    "preview": "export const isWindows = process.platform === 'win32';\nexport const isMac = process.platform === 'darwin';\nexport const "
  },
  {
    "path": "src/utils/playList.js",
    "chars": 1859,
    "preview": "import router from '../router';\nimport state from '../store/state';\nimport {\n  recommendPlaylist,\n  dailyRecommendPlayli"
  },
  {
    "path": "src/utils/request.js",
    "chars": 2507,
    "preview": "import router from '@/router';\nimport { doLogout, getCookie } from '@/utils/auth';\nimport axios from 'axios';\n\nlet baseU"
  },
  {
    "path": "src/utils/shortcuts.js",
    "chars": 1042,
    "preview": "// default shortcuts\n// for more info, check https://www.electronjs.org/docs/api/accelerator\n\nexport default [\n  {\n    i"
  },
  {
    "path": "src/utils/staticData.js",
    "chars": 5725,
    "preview": "export const byAppleMusic = [\n  {\n    coverImgUrl:\n      'https://p2.music.126.net/GvYQoflE99eoeGi9jG4Bsw==/109951165375"
  },
  {
    "path": "src/utils/updateApp.js",
    "chars": 1715,
    "preview": "import initLocalStorage from '@/store/initLocalStorage.js';\nimport pkg from '../../package.json';\n\nconst updateSetting ="
  },
  {
    "path": "src/views/album.vue",
    "chars": 12051,
    "preview": "<template>\n  <div v-show=\"show\" class=\"album-page\">\n    <div class=\"playlist-info\">\n      <Cover\n        :id=\"album.id\"\n"
  },
  {
    "path": "src/views/artist.vue",
    "chars": 14032,
    "preview": "<template>\n  <div v-show=\"show\" class=\"artist-page\">\n    <div class=\"artist-info\">\n      <div class=\"head\">\n        <img"
  },
  {
    "path": "src/views/artistMV.vue",
    "chars": 2073,
    "preview": "<template>\n  <div v-show=\"show\">\n    <h1>\n      <img\n        class=\"avatar\"\n        :src=\"artist.img1v1Url | resizeImage"
  },
  {
    "path": "src/views/dailyTracks.vue",
    "chars": 2566,
    "preview": "<template>\n  <div v-show=\"show\">\n    <div class=\"special-playlist\">\n      <div class=\"title gradient\"> 每日歌曲推荐 </div>\n   "
  },
  {
    "path": "src/views/explore.vue",
    "chars": 7286,
    "preview": "<template>\n  <div class=\"explore-page\">\n    <h1>{{ $t('explore.explore') }}</h1>\n    <div class=\"buttons\">\n      <div\n  "
  },
  {
    "path": "src/views/home.vue",
    "chars": 4850,
    "preview": "<template>\n  <div v-show=\"show\" class=\"home\">\n    <div\n      v-if=\"settings.showPlaylistsByAppleMusic !== false\"\n      c"
  },
  {
    "path": "src/views/lastfmCallback.vue",
    "chars": 2010,
    "preview": "<template>\n  <div class=\"lastfm-callback\">\n    <div class=\"section-1\">\n      <img src=\"/img/logos/yesplaymusic.png\" />\n "
  },
  {
    "path": "src/views/library.vue",
    "chars": 15941,
    "preview": "<template>\n  <div v-show=\"show\" ref=\"library\">\n    <h1>\n      <img\n        class=\"avatar\"\n        :src=\"data.user.avatar"
  },
  {
    "path": "src/views/login.vue",
    "chars": 2978,
    "preview": "<template>\n  <div class=\"login\">\n    <div class=\"section-1\">\n      <img src=\"/img/logos/yesplaymusic.png\" />\n      <svg-"
  },
  {
    "path": "src/views/loginAccount.vue",
    "chars": 12075,
    "preview": "<template>\n  <div class=\"login\">\n    <div class=\"login-container\">\n      <div class=\"section-1\">\n        <img src=\"/img/"
  },
  {
    "path": "src/views/loginUsername.vue",
    "chars": 4328,
    "preview": "<template>\n  <div class=\"login\">\n    <div>\n      <div class=\"title\">{{ $t('login.usernameLogin') }}</div>\n      <div cla"
  },
  {
    "path": "src/views/lyrics.vue",
    "chars": 28909,
    "preview": "<template>\n  <transition name=\"slide-up\">\n    <div\n      class=\"lyrics-page\"\n      :class=\"{ 'no-lyric': noLyric }\"\n    "
  },
  {
    "path": "src/views/mv.vue",
    "chars": 5422,
    "preview": "<template>\n  <div class=\"mv-page\">\n    <div class=\"current-video\">\n      <div class=\"video\">\n        <video ref=\"videoPl"
  },
  {
    "path": "src/views/newAlbum.vue",
    "chars": 828,
    "preview": "<template>\n  <div class=\"newAlbum\">\n    <h1>{{ $t('home.newAlbum') }}</h1>\n    <div class=\"playlist-row\">\n      <div cla"
  },
  {
    "path": "src/views/next.vue",
    "chars": 3229,
    "preview": "<template>\n  <div class=\"next-tracks\">\n    <h1>{{ $t('next.nowPlaying') }}</h1>\n    <TrackList\n      :tracks=\"[currentTr"
  },
  {
    "path": "src/views/playlist.vue",
    "chars": 22476,
    "preview": "<template>\n  <div v-show=\"show\" class=\"playlist\">\n    <div\n      v-if=\"specialPlaylistInfo === undefined && !isLikeSongs"
  },
  {
    "path": "src/views/search.vue",
    "chars": 6927,
    "preview": "<template>\n  <div v-show=\"show\" class=\"search-page\">\n    <div v-show=\"artists.length > 0 || albums.length > 0\" class=\"ro"
  },
  {
    "path": "src/views/searchType.vue",
    "chars": 3863,
    "preview": "<template>\n  <div v-show=\"show\" class=\"search\">\n    <h1>\n      <span>{{ $t('search.searchFor') }} {{ typeNameTable[type]"
  },
  {
    "path": "src/views/settings.vue",
    "chars": 55235,
    "preview": "<template>\n  <div class=\"settings-page\" @click=\"clickOutside\">\n    <div class=\"container\">\n      <div v-if=\"showUserInfo"
  },
  {
    "path": "vercel.example.json",
    "chars": 136,
    "preview": "{\n  \"rewrites\": [\n    {\n      \"source\": \"/api/:match*\",\n      \"destination\": \"https://your-netease-api.example.com/:matc"
  },
  {
    "path": "vue.config.js",
    "chars": 5270,
    "preview": "const webpack = require('webpack');\nconst path = require('path');\nfunction resolve(dir) {\n  return path.join(__dirname, "
  }
]

// ... and 2 more files (download for full content)

About this extraction

This page contains the full source code of the qier222/YesPlayMusic GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 126 files (565.3 KB), approximately 167.4k tokens, and a symbol index with 244 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!