feat: 实现收藏歌单、修复TopBar拖拽穿透问题、适配Linux amd64

This commit is contained in:
2026-08-13 11:00:03 +08:00
parent adbf9ca5cf
commit 1c63cd0139
27 changed files with 1104 additions and 141 deletions

126
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
# QZMusic 自动构建工作流
#
# 触发方式:
# - push 到 master
# - 推送到 v* tag (会自动创建 GitHub Release 并附上安装包)
# - 手动触发 (workflow_dispatch)
#
# 产物:
# - Linux (amd64): AppImage + deb
# - Windows (x64): NSIS 安装包
#
# 注意: 请在提交前确保以下二进制已纳入 git 版本库 (否则 CI 拉不到):
# core/qzplayer (Linux 播放核心, ELF)
# core/libfftw3f.so.3 (FFTW 单精度运行库)
# core/qzplayer.exe (Windows 播放核心)
# core/libfftw3f-3.dll (Windows FFTW)
# native/taglib_reader/build/taglib_reader_cli.exe (Windows 标签扫描器)
name: Build
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
workflow_dispatch:
permissions:
contents: read
jobs:
build-linux:
name: Linux (amd64)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install TagLib (build taglib_reader)
run: |
sudo apt-get update
sudo apt-get install -y libtag1-dev
- name: Build taglib_reader (local music scanner)
run: bash native/taglib_reader/build.sh
- name: Ensure native binaries are executable
run: |
chmod +x core/qzplayer
chmod +x native/taglib_reader/build/taglib_reader_cli
- name: Build Linux packages
run: bun run electron:build:linux
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: qzmusic-linux-amd64
path: |
release/*.AppImage
release/*.deb
if-no-files-found: error
build-windows:
name: Windows (x64)
runs-on: windows-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build Windows installer
run: bun run electron:build
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: qzmusic-windows-x64
path: |
release/*.exe
release/*.blockmap
if-no-files-found: error
release:
name: Create GitHub Release
needs: [build-linux, build-windows]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Publish release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

103
CLAUDE.md Normal file
View File

@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
QZ Music is a cross-platform desktop music player (Electron + Vue 3 + TypeScript). The backend is a separate FastAPI Python project at `C:\Develop\SuperApi\app_api`. This repo is the Electron client only.
## Build & Dev Commands
```bash
# Install dependencies
npm install
# Development (hot reload)
npm run dev
# Production build
npm run build
# Build Windows installer
npm run electron:build
```
No test runner is configured. No linter is configured.
## Architecture
### Three-Process Model (Critical)
The app uses Electron's three-process architecture with strict separation:
```
Renderer (Vue) → Preload (IPC bridge) → Main Process → HTTP fetch → Backend API
```
**The renderer process NEVER makes HTTP requests directly.** All API calls go through this chain:
1. **Renderer** calls `window.electronAPI.xxx()` methods
2. **Preload** (`src/preload/index.ts`) maps each method to `ipcRenderer.invoke('channel:name', args)`
3. **Main process** (`src/main/index.ts`) registers `ipcMain.handle('channel:name', handler)`
4. **Main process** calls the backend via `qzFetch()` (defined in `src/main/authStore.ts`) which uses native `fetch()`
To add a new API feature, you must touch all 4 layers:
- Backend endpoint (FastAPI)
- Main process function (`src/main/authStore.ts` or similar)
- IPC handler (`src/main/index.ts`)
- Preload bridge (`src/preload/index.ts`)
- Type definition (`src/renderer/src/types/electron.d.ts`)
### Backend API
- Base URL: `https://api.qz.shiqianjiang.cn/app`
- Auth: JWT Bearer token, auto-refreshed via `getValidAccessToken()`
- `qzFetch(path, init)` is the universal API caller — attaches auth, handles errors
### Key Directories
```
src/main/ - Electron main process (window, IPC handlers, audio engine control)
authStore.ts - Auth state, token management, qzFetch(), user API calls
playlistStore.ts - Playlist CRUD (local JSON files + cloud API)
qzpController.ts - IPC controller for QZPlayer (C audio engine binary)
proxyServer.ts - Local HTTP proxy (:5266) that streams remote music to QZPlayer
pluginSystem.ts - Plugin framework for music source plugins
settingsStore.ts - App settings persistence
src/preload/ - Context bridge (ONLY file that can use both Node and browser APIs)
index.ts - Defines the complete window.electronAPI surface
src/renderer/src/ - Vue 3 frontend
main.ts - App entry, Vue Router config, Pinia setup
stores/ - Pinia stores (player, playlists, auth, listenTogether)
views/ - Page components (Playlist.vue is reused for 5+ routes)
components/ - Shared UI components
types/ - TypeScript interfaces (electron.d.ts is the IPC contract)
```
### Audio Pipeline
QZPlayer is a C binary (`core/` directory) that plays audio via WASAPI+FFmpeg. Communication:
- Main process sends commands via IPC (`src/main/qzpController.ts`)
- Music is streamed through a local HTTP proxy (`src/main/proxyServer.ts` on port 5266)
- URL format: `http://localhost:5266/music?source={source}&id={id}&quality={quality}`
### Local vs Cloud Playlists
- **Local** (`scope: 'local'`): JSON files in `userData/playlists/`, UUID-based IDs
- **Cloud** (`scope: 'cloud'`): Backend API at `/playlist/*`, auto-incrementing numeric IDs
- **Plugin** (`scope: 'plugin'`): Read-only collections from music source plugins
### AMLL (Apple Music-like Lyrics)
`amll-local/` is a local copy of the AMLL library (lyrics rendering + background effects). It's aliased in `electron.vite.config.ts` to resolve from source. Do not modify AMLL packages directly — they are a third-party dependency.
## Conventions
- Use `Icon` component from `@iconify/vue` for all icons (e.g. `<Icon icon="lucide:play" />`)
- UI component library: Element Plus (`ElMessage` for toasts, `ElMessageBox` for confirmations)
- State management: Pinia stores in `src/renderer/src/stores/`
- Routing: Vue Router with hash history, defined inline in `src/renderer/src/main.ts`
- CSS: Scoped styles with CSS custom properties (`--color-accent`, `--color-bg-*`, etc.)
- `Playlist.vue` is a monolithic view serving multiple routes (Liked, Recent, PlaylistDetail, UserLikedPlaylist, PluginCollection) — use `route.name` and computed properties to branch behavior

View File

@@ -15,6 +15,52 @@
| **QZ Plugins** | 高拓展性的插件运行环境 |
| **AMLL** | 背景渲染 |
| **QZPlayer** | 基于WASAPI和FFmpeg的轻量级模块化音频播放器,使用C编写,IPC与主程序通信 |
## 🐧 Linux (amd64) 支持
```bash
# 安装 TagLib 开发库 (用于编译本地音乐标签扫描器)
# Debian / Ubuntu
sudo apt install -y libtag1-dev
# Fedora
sudo dnf install -y taglib-devel
# Arch Linux
sudo pacman -S --needed taglib
# 编译本地音乐标签扫描器
bash native/taglib_reader/build.sh
# 确保播放核心二进制有可执行位 (Windows 下提交的二进制会丢失该位)
chmod +x core/qzplayer native/taglib_reader/build/taglib_reader_cli
# 安装依赖并打包
bun install
bun run electron:build:linux
# 产物: release/*.AppImage 与 release/*.deb
```
### 运行时依赖
安装/运行 Linux 版时需要以下系统库(`libfftw3f.so.3` 已随安装包内置,无需手动安装):
| 依赖 | Debian / Ubuntu | 说明 |
|------|----------------|------|
| ALSA | `libasound2` | 音频输出(必需) |
| TagLib | `libtag1` | 本地音乐标签扫描(不使用本地音乐可不装) |
| zlib | `zlib1g` | 通常已预装 |
| FFTW单精度 | `libfftw3-3` | 已内置,无需安装 |
```bash
# Debian / Ubuntu
sudo apt install -y libasound2 libtag1
# Fedora
sudo dnf install -y alsa-lib taglib
# Arch Linux
sudo pacman -S --needed alsa-lib taglib
```
## 📖 项目说明
本项目为 **Vue + Electron** 的学习实践作品旨在完善QZ Music的多平台生态。

BIN
core/libfftw3f.so.3 Normal file

Binary file not shown.

BIN
core/qzplayer Normal file

Binary file not shown.

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# 在 Linux 上编译 taglib_reader_cli (本地音乐标签扫描器)
# 依赖: g++ (C++17), TagLib (pkg-config 或手动指定 TAGLIB_PREFIX)
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
outDir="$root/build"
mkdir -p "$outDir"
cliSrc="$root/taglib_reader_cli.cpp"
cliOut="$outDir/taglib_reader_cli"
if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists taglib; then
taglib_cflags="$(pkg-config --cflags taglib)"
taglib_libs="$(pkg-config --libs taglib)"
else
TAGLIB_PREFIX="${TAGLIB_PREFIX:-/usr/local}"
taglib_cflags="-I${TAGLIB_PREFIX}/include"
taglib_libs="-L${TAGLIB_PREFIX}/lib -ltag"
fi
# 注意: taglib 依赖 zlib (静态链接时尤其需要显式 -lz)
g++ -std=c++17 -O2 -fPIC ${taglib_cflags} "$cliSrc" -o "$cliOut" ${taglib_libs} -lz
echo "Built $cliOut"

View File

@@ -6,7 +6,12 @@
#include <string>
#include <vector>
#include <cctype>
#include <cstdint>
#ifdef _WIN32
#include <windows.h>
#endif
#include <taglib/attachedpictureframe.h>
#include <taglib/fileref.h>
@@ -31,6 +36,7 @@ namespace {
constexpr unsigned int kMaxCoverBytes = 8 * 1024 * 1024;
#ifdef _WIN32
std::string WideToUtf8(const std::wstring &value) {
if (value.empty()) return "";
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
@@ -48,6 +54,25 @@ std::wstring Utf8ToWide(const std::string &value) {
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
#endif
// 将文件路径转成 UTF-8 字符串 (Windows 底层是 UTF-16, Unix 本身就是 UTF-8)
std::string PathToUtf8(const fs::path &value) {
#ifdef _WIN32
return WideToUtf8(value.wstring());
#else
return value.string();
#endif
}
// 从 UTF-8 字符串构造文件路径
fs::path PathFromUtf8(const std::string &value) {
#ifdef _WIN32
return fs::path(Utf8ToWide(value));
#else
return fs::path(value);
#endif
}
std::string ToUtf8(const TagLib::String &value) {
return value.to8Bit(true);
@@ -131,13 +156,13 @@ std::string WriteCoverFile(
fs::create_directories(artworkDir, ec);
if (ec) return "";
const auto id = HexHash(WideToUtf8(filePath.wstring()));
const auto outPath = artworkDir / fs::path(Utf8ToWide(id + CoverExtension(mime)));
const auto id = HexHash(PathToUtf8(filePath));
const auto outPath = artworkDir / fs::path(id + CoverExtension(mime));
std::ofstream out(outPath, std::ios::binary | std::ios::trunc);
if (!out) return "";
out.write(cover.data(), cover.size());
if (!out) return "";
return WideToUtf8(outPath.wstring());
return PathToUtf8(outPath);
}
std::string Base64Encode(const TagLib::ByteVector &data) {
@@ -282,20 +307,23 @@ std::string ReadLyrics(TagLib::File *file) {
}
bool IsAudioPath(const fs::path &file) {
const auto ext = file.extension().wstring();
const std::wstring lower = [&]() {
std::wstring out = ext;
for (auto &ch : out) ch = static_cast<wchar_t>(towlower(ch));
return out;
}();
return lower == L".mp3" || lower == L".flac" || lower == L".m4a" || lower == L".mp4" ||
lower == L".aac" || lower == L".ogg" || lower == L".opus" || lower == L".wav" ||
lower == L".aiff" || lower == L".aif" || lower == L".ape" || lower == L".wv";
std::string ext = file.extension().string();
for (auto &ch : ext) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
return ext == ".mp3" || ext == ".flac" || ext == ".m4a" || ext == ".mp4" ||
ext == ".aac" || ext == ".ogg" || ext == ".opus" || ext == ".wav" ||
ext == ".aiff" || ext == ".aif" || ext == ".ape" || ext == ".wv";
}
TagLib::FileRef OpenFileRef(const fs::path &filePath) {
#ifdef _WIN32
return TagLib::FileRef(TagLib::FileName(filePath.wstring().c_str()), true, TagLib::AudioProperties::Average);
#else
return TagLib::FileRef(TagLib::FileName(filePath.c_str()), true, TagLib::AudioProperties::Average);
#endif
}
std::string ReadFileJson(const fs::path &filePath, const fs::path &artworkDir = {}) {
const std::wstring nativePath = filePath.wstring();
TagLib::FileRef fileRef(TagLib::FileName(nativePath.c_str()), true, TagLib::AudioProperties::Average);
TagLib::FileRef fileRef = OpenFileRef(filePath);
if (fileRef.isNull() || !fileRef.file()) {
throw std::runtime_error("Unsupported or unreadable audio file");
}
@@ -315,7 +343,7 @@ std::string ReadFileJson(const fs::path &filePath, const fs::path &artworkDir =
std::ostringstream json;
json << "{";
json << "\"path\":" << Q(WideToUtf8(filePath.wstring())) << ",";
json << "\"path\":" << Q(PathToUtf8(filePath)) << ",";
json << "\"title\":" << Q(tag ? ToUtf8(tag->title()) : "") << ",";
json << "\"artist\":" << Q(tag ? ToUtf8(tag->artist()) : "") << ",";
json << "\"album\":" << Q(tag ? ToUtf8(tag->album()) : "") << ",";
@@ -352,31 +380,28 @@ std::vector<fs::path> CollectAudioFiles(const std::vector<fs::path> &roots) {
return files;
}
} // namespace
int wmain(int argc, wchar_t **argv) {
SetConsoleOutputCP(CP_UTF8);
if (argc < 3) {
std::cerr << "Usage: taglib_reader_cli.exe read <file> | scan [--artwork-dir <dir>] <dir...>\n";
int run(const std::vector<std::string> &args) {
if (args.size() < 3) {
std::cerr << "Usage: taglib_reader_cli read <file> | scan [--artwork-dir <dir>] <dir...>\n";
return 2;
}
try {
const std::wstring mode = argv[1];
if (mode == L"read") {
std::cout << ReadFileJson(fs::path(argv[2]));
const std::string mode = args[1];
if (mode == "read") {
std::cout << ReadFileJson(PathFromUtf8(args[2]));
return 0;
}
if (mode == L"scan") {
if (mode == "scan") {
fs::path artworkDir;
std::vector<fs::path> roots;
int startIndex = 2;
if (argc >= 5 && std::wstring(argv[2]) == L"--artwork-dir") {
artworkDir = fs::path(argv[3]);
if (args.size() >= 5 && args[2] == "--artwork-dir") {
artworkDir = PathFromUtf8(args[3]);
startIndex = 4;
}
for (int i = startIndex; i < argc; i++) roots.emplace_back(argv[i]);
for (size_t i = static_cast<size_t>(startIndex); i < args.size(); i++) roots.emplace_back(PathFromUtf8(args[i]));
const auto files = CollectAudioFiles(roots);
std::cout << "{\"songs\":[";
@@ -387,7 +412,7 @@ int wmain(int argc, wchar_t **argv) {
std::cout << ReadFileJson(file, artworkDir);
first = false;
} catch (const std::exception &error) {
std::cerr << "Failed to read " << WideToUtf8(file.wstring()) << ": " << error.what() << "\n";
std::cerr << "Failed to read " << PathToUtf8(file) << ": " << error.what() << "\n";
}
}
std::cout << "]}";
@@ -401,3 +426,22 @@ int wmain(int argc, wchar_t **argv) {
return 1;
}
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t **argv) {
SetConsoleOutputCP(CP_UTF8);
std::vector<std::string> args;
args.reserve(static_cast<size_t>(argc));
for (int i = 0; i < argc; i++) args.push_back(WideToUtf8(argv[i]));
return run(args);
}
#else
int main(int argc, char **argv) {
std::vector<std::string> args;
args.reserve(static_cast<size_t>(argc));
for (int i = 0; i < argc; i++) args.push_back(argv[i]);
return run(args);
}
#endif

View File

@@ -3,12 +3,14 @@
"private": true,
"author": "lqtmcstudio",
"description": "QZMusic - 简洁、美观、拓展性强的音乐播放器",
"version": "1.0.2",
"homepage": "https://github.com/lqtmcstudio/QZMusic_PC",
"version": "1.0.3",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"electron:build": "electron-vite build && electron-builder --win --x64 --config --publish never"
"electron:build": "electron-vite build && electron-builder --win --x64 --config --publish never",
"electron:build:linux": "electron-vite build && electron-builder --linux --x64 --config --publish never"
},
"dependencies": {
"@applemusic-like-lyrics/core": "file:amll-local/packages/core",
@@ -62,7 +64,46 @@
},
"win": {
"target": "nsis",
"icon": "public/icon.ico"
"icon": "public/icon.ico",
"extraResources": [
{
"from": "core/qzplayer.exe",
"to": "core/qzplayer.exe"
},
{
"from": "core/libfftw3f-3.dll",
"to": "core/libfftw3f-3.dll"
},
{
"from": "native/taglib_reader/build/taglib_reader_cli.exe",
"to": "native/taglib_reader_cli.exe"
}
]
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "public/icon.png",
"category": "Audio",
"executableName": "qzmusic",
"maintainer": "lqtmcstudio <lqtmcstudio@126.com>",
"vendor": "lqtmcstudio",
"extraResources": [
{
"from": "core/qzplayer",
"to": "core/qzplayer"
},
{
"from": "core/libfftw3f.so.3",
"to": "core/libfftw3f.so.3"
},
{
"from": "native/taglib_reader/build/taglib_reader_cli",
"to": "native/taglib_reader_cli"
}
]
},
"nsis": {
"oneClick": false,
@@ -71,12 +112,8 @@
},
"extraResources": [
{
"from": "core/",
"to": "core/"
},
{
"from": "native/taglib_reader/build/taglib_reader_cli.exe",
"to": "native/taglib_reader_cli.exe"
"from": "core/ffmpeg_license/",
"to": "core/ffmpeg_license/"
}
]
}

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,129 @@
# License
Most files in FFmpeg are under the GNU Lesser General Public License version 2.1
or later (LGPL v2.1+). Read the file `COPYING.LGPLv2.1` for details. Some other
files have MIT/X11/BSD-style licenses. In combination the LGPL v2.1+ applies to
FFmpeg.
Some optional parts of FFmpeg are licensed under the GNU General Public License
version 2 or later (GPL v2+). See the file `COPYING.GPLv2` for details. None of
these parts are used by default, you have to explicitly pass `--enable-gpl` to
configure to activate them. In this case, FFmpeg's license changes to GPL v2+.
Specifically, the GPL parts of FFmpeg are:
- libpostproc
- optional x86 optimization in the files
- `libavcodec/x86/flac_dsp_gpl.asm`
- `libavcodec/x86/idct_mmx.c`
- `libavfilter/x86/vf_removegrain.asm`
- the following building and testing tools
- `compat/solaris/make_sunver.pl`
- `doc/t2h.pm`
- `doc/texi2pod.pl`
- `libswresample/tests/swresample.c`
- `tests/checkasm/*`
- `tests/tiny_ssim.c`
- the following filters in libavfilter:
- `signature_lookup.c`
- `vf_blackframe.c`
- `vf_boxblur.c`
- `vf_colormatrix.c`
- `vf_cover_rect.c`
- `vf_cropdetect.c`
- `vf_delogo.c`
- `vf_eq.c`
- `vf_find_rect.c`
- `vf_fspp.c`
- `vf_histeq.c`
- `vf_hqdn3d.c`
- `vf_kerndeint.c`
- `vf_lensfun.c` (GPL version 3 or later)
- `vf_mcdeint.c`
- `vf_mpdecimate.c`
- `vf_nnedi.c`
- `vf_owdenoise.c`
- `vf_perspective.c`
- `vf_phase.c`
- `vf_pp.c`
- `vf_pp7.c`
- `vf_pullup.c`
- `vf_repeatfields.c`
- `vf_sab.c`
- `vf_signature.c`
- `vf_smartblur.c`
- `vf_spp.c`
- `vf_stereo3d.c`
- `vf_super2xsai.c`
- `vf_tinterlace.c`
- `vf_uspp.c`
- `vf_vaguedenoiser.c`
- `vsrc_mptestsrc.c`
Should you, for whatever reason, prefer to use version 3 of the (L)GPL, then
the configure parameter `--enable-version3` will activate this licensing option
for you. Read the file `COPYING.LGPLv3` or, if you have enabled GPL parts,
`COPYING.GPLv3` to learn the exact legal terms that apply in this case.
There are a handful of files under other licensing terms, namely:
* The files `libavcodec/jfdctfst.c`, `libavcodec/jfdctint_template.c` and
`libavcodec/jrevdct.c` are taken from libjpeg, see the top of the files for
licensing details. Specifically note that you must credit the IJG in the
documentation accompanying your program if you only distribute executables.
You must also indicate any changes including additions and deletions to
those three files in the documentation.
* `tests/reference.pnm` is under the expat license.
## External libraries
FFmpeg can be combined with a number of external libraries, which sometimes
affect the licensing of binaries resulting from the combination.
### Compatible libraries
The following libraries are under GPL version 2:
- avisynth
- frei0r
- libcdio
- libdavs2
- librubberband
- libvidstab
- libx264
- libx265
- libxavs
- libxavs2
- libxvid
When combining them with FFmpeg, FFmpeg needs to be licensed as GPL as well by
passing `--enable-gpl` to configure.
The following libraries are under LGPL version 3:
- gmp
- libaribb24
- liblensfun
When combining them with FFmpeg, use the configure option `--enable-version3` to
upgrade FFmpeg to the LGPL v3.
The VMAF, mbedTLS, RK MPI, OpenCORE and VisualOn libraries are under the Apache License
2.0. That license is incompatible with the LGPL v2.1 and the GPL v2, but not with
version 3 of those licenses. So to combine these libraries with FFmpeg, the
license version needs to be upgraded by passing `--enable-version3` to configure.
The smbclient library is under the GPL v3, to combine it with FFmpeg,
the options `--enable-gpl` and `--enable-version3` have to be passed to
configure to upgrade FFmpeg to the GPL v3.
### Incompatible libraries
There are certain libraries you can combine with FFmpeg whose licenses are not
compatible with the GPL and/or the LGPL. If you wish to enable these
libraries, even in circumstances that their license may be incompatible, pass
`--enable-nonfree` to configure. This will cause the resulting binary to be
unredistributable.
The Fraunhofer FDK AAC and OpenSSL libraries are under licenses which are
incompatible with the GPLv2 and v3. To the best of our knowledge, they are
compatible with the LGPL.

View File

@@ -395,16 +395,57 @@ export async function addRecentSong(userId: string, song: any): Promise<any> {
})
}
// === Playlist Likes ===
// === Favorite Playlists ===
export async function togglePlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> {
return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`, {
export interface FavoritePlaylistRef {
id: string
source: string
}
export interface FavoritePlaylistStatus {
status: string
collected: boolean
collection_count?: number | null
message?: string | null
}
function normalizeFavoritePlaylistSource(source: string): string {
const normalized = String(source || '').trim()
return normalized === 'cloud' ? '' : normalized
}
export async function getFavoritePlaylists(userId?: string): Promise<FavoritePlaylistRef[]> {
const state = loadAuthState()
const targetUserId = userId || state.userInfo?.id
if (!targetUserId) throw new Error('Not logged in')
const result = await qzFetch(`/user/${encodeURIComponent(targetUserId)}/fav/playlists`)
return Array.isArray(result) ? result : []
}
export async function collectPlaylist(playlistId: string, source = ''): Promise<FavoritePlaylistStatus> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists`, {
method: 'POST',
body: JSON.stringify({
id: String(playlistId || '').trim(),
source: normalizeFavoritePlaylistSource(source),
}),
})
}
export async function getPlaylistLike(playlistId: string): Promise<{ status: string; liked: boolean; like_count: number }> {
return qzFetch(`/playlist/${encodeURIComponent(playlistId)}/like`)
export async function uncollectPlaylist(playlistId: string, source = ''): Promise<FavoritePlaylistStatus> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
const query = new URLSearchParams({
id: String(playlistId || '').trim(),
source: normalizeFavoritePlaylistSource(source),
})
return qzFetch(`/user/${encodeURIComponent(userId)}/fav/playlists?${query.toString()}`, {
method: 'DELETE',
})
}
// === User Follow ===

View File

@@ -31,8 +31,9 @@ import {
uploadImage,
getRecentSongs,
addRecentSong,
togglePlaylistLike,
getPlaylistLike,
getFavoritePlaylists,
collectPlaylist,
uncollectPlaylist,
toggleUserFollow,
getUserSubscriptions,
type AuthCallbackPayload,
@@ -375,12 +376,13 @@ ipcMain.handle('user:addRecentSong', (_event, userId: string, song: any) => {
return addRecentSong(String(userId || ''), song || {})
})
// Playlist Likes
ipcMain.handle('playlist:toggleLike', (_event, playlistId: string) => {
return togglePlaylistLike(String(playlistId || ''))
// Favorite Playlists
ipcMain.handle('playlist:getFavorites', () => getFavoritePlaylists())
ipcMain.handle('playlist:collect', (_event, playlistId: string, source = '') => {
return collectPlaylist(String(playlistId || ''), String(source || ''))
})
ipcMain.handle('playlist:getLike', (_event, playlistId: string) => {
return getPlaylistLike(String(playlistId || ''))
ipcMain.handle('playlist:uncollect', (_event, playlistId: string, source = '') => {
return uncollectPlaylist(String(playlistId || ''), String(source || ''))
})
// User Follow
@@ -421,8 +423,8 @@ ipcMain.handle('playlist:publicList', (_event, search = '', sort = 'visit', page
return listPublicPlaylists(String(search || ''), String(sort || 'visit'), Number(page) || 1, Number(limit) || 50)
})
ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string) => {
return getPlaylist(scope, id)
ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string, recordVisit = true) => {
return getPlaylist(scope, id, Boolean(recordVisit))
})
ipcMain.handle('playlist:create', (_event, scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {

View File

@@ -54,12 +54,18 @@ function getLibraryPath(): string {
}
function getReaderExe(): string {
const binaryName = process.platform === 'win32' ? 'taglib_reader_cli.exe' : 'taglib_reader_cli';
const candidates = [
path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', 'taglib_reader_cli.exe'),
path.join(process.resourcesPath || '', 'native', 'taglib_reader_cli.exe'),
path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', binaryName),
path.join(process.resourcesPath || '', 'native', binaryName),
]
const target = candidates.find((candidate) => candidate && fs.existsSync(candidate))
if (!target) throw new Error('TagLib reader executable not found')
if (!target) throw new Error(`TagLib reader executable not found (${binaryName})`)
if (process.platform !== 'win32') {
try {
fs.chmodSync(target, 0o755)
} catch {}
}
return target
}
@@ -68,14 +74,22 @@ function getArtworkDir(): string {
}
function getDefaultRoots(): string[] {
const username = path.basename(app.getPath('home'))
const roots: string[] = []
for (let code = 67; code <= 90; code++) {
const drive = `${String.fromCharCode(code)}:\\`
if (!fs.existsSync(drive)) continue
const userRoot = path.join(drive, 'Users', username)
if (process.platform === 'win32') {
const username = path.basename(app.getPath('home'))
for (let code = 67; code <= 90; code++) {
const drive = `${String.fromCharCode(code)}:\\`
if (!fs.existsSync(drive)) continue
const userRoot = path.join(drive, 'Users', username)
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
const candidate = path.join(userRoot, dirName)
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
}
}
} else {
const home = app.getPath('home')
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
const candidate = path.join(userRoot, dirName)
const candidate = path.join(home, dirName)
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
}
}

View File

@@ -30,7 +30,7 @@ export interface PlaylistInfo {
author?: string
play_count?: string
visit_count?: number
like_count?: number
collection_count?: number
is_public?: boolean
}
@@ -138,7 +138,7 @@ function normalizeCloudPlaylist(raw: any): AppPlaylist {
author: info?.author || '',
play_count: info?.play_count || '',
visit_count: Number(info?.visit_count ?? info?.play_count ?? 0) || 0,
like_count: Number(info?.like_count ?? 0) || 0,
collection_count: Number(info?.collection_count ?? info?.like_count ?? 0) || 0,
is_public: Boolean(info?.is_public ?? info?.public ?? false),
},
list,
@@ -269,7 +269,7 @@ export async function listPublicPlaylists(
const query = new URLSearchParams({
page: String(Math.max(1, Number(page) || 1)),
limit: String(Math.max(1, Math.min(50, Number(limit) || 50))),
sort: ['visit', 'name', 'total', 'like'].includes(sort) ? sort : 'visit',
sort: ['visit', 'name', 'total', 'collection'].includes(sort) ? sort : 'visit',
})
if (search.trim()) query.set('search', search.trim())
const raw = await qzFetch(`/playlist/public?${query.toString()}`)
@@ -283,9 +283,9 @@ export async function listPublicPlaylists(
}
}
export async function getPlaylist(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
export async function getPlaylist(scope: PlaylistScope, id: string, recordVisit = true): Promise<AppPlaylist> {
if (scope === 'local') return readLocalPlaylist(id)
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}`)
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}?record_visit=${recordVisit}`)
return normalizeCloudPlaylist(raw)
}

View File

@@ -1,6 +1,7 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { Socket } from 'node:net';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
@@ -35,15 +36,25 @@ export class QzpController extends EventEmitter {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\qzplayer';
}
return '/tmp/qzmusic_mpv_socket';
return '/tmp/qzplayer.sock';
}
private getCorePath(): string {
const appRoot = process.env.APP_ROOT || process.cwd();
const binaryName = process.platform === 'win32' ? 'qzplayer.exe' : 'qzplayer';
if (app.isPackaged) {
return path.join(process.resourcesPath, 'core', 'qzplayer.exe');
return path.join(process.resourcesPath, 'core', binaryName);
}
return path.join(appRoot, 'core', binaryName);
}
private isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
return path.join(appRoot, 'core', 'qzplayer.exe');
}
start(): void {
@@ -56,9 +67,36 @@ export class QzpController extends EventEmitter {
console.log('Starting QZPlayer from:', playerPath);
try {
const child = spawn(playerPath, [], {
const env: NodeJS.ProcessEnv = { ...process.env };
let spawnPath = playerPath;
if (process.platform !== 'win32') {
// 让动态链接器在二进制同级目录查找自带共享库 (如 libfftw3f.so.3)
const coreDir = path.dirname(playerPath);
env.LD_LIBRARY_PATH = coreDir + (process.env.LD_LIBRARY_PATH ? path.delimiter + process.env.LD_LIBRARY_PATH : '');
// git / Windows 文件系统可能丢失可执行位, 打包到 Linux 后需要补上
if (!this.isExecutable(spawnPath)) {
try { fs.chmodSync(spawnPath, 0o755); } catch { /* ignore */ }
}
if (!this.isExecutable(spawnPath)) {
// AppImage 只读挂载 / deb 安装到 root 目录时无 chmod 权限, 复制到用户数据目录再执行
const altPath = path.join(app.getPath('userData'), 'bin', path.basename(spawnPath));
try {
fs.mkdirSync(path.dirname(altPath), { recursive: true });
fs.copyFileSync(spawnPath, altPath);
fs.chmodSync(altPath, 0o755);
spawnPath = altPath;
console.log('QZPlayer copied to writable path:', altPath);
} catch (err) {
console.warn('Failed to prepare writable QZPlayer:', err);
}
}
}
const child = spawn(spawnPath, [], {
stdio: 'ignore',
windowsHide: true,
env,
});
this.process = child;

View File

@@ -88,7 +88,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
playlist: {
list: () => ipcRenderer.invoke('playlist:list'),
publicList: (search = '', sort = 'visit', page = 1, limit = 50) => ipcRenderer.invoke('playlist:publicList', search, sort, page, limit),
get: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:get', scope, id),
get: (scope: 'local' | 'cloud', id: string, recordVisit = true) => ipcRenderer.invoke('playlist:get', scope, id, recordVisit),
create: (scope: 'local' | 'cloud', data: { name: string; desc?: string; is_public?: boolean }) => ipcRenderer.invoke('playlist:create', scope, data),
update: (scope: 'local' | 'cloud', id: string, info: any) => ipcRenderer.invoke('playlist:update', scope, id, info),
delete: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:delete', scope, id),
@@ -98,8 +98,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
import: () => ipcRenderer.invoke('playlist:import'),
convertScope: (scope: 'local' | 'cloud', id: string, targetScope: 'local' | 'cloud') => ipcRenderer.invoke('playlist:convertScope', scope, id, targetScope),
copyToLocal: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:copyToLocal', scope, id),
toggleLike: (playlistId: string) => ipcRenderer.invoke('playlist:toggleLike', playlistId),
getLike: (playlistId: string) => ipcRenderer.invoke('playlist:getLike', playlistId),
getFavorites: () => ipcRenderer.invoke('playlist:getFavorites'),
collect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:collect', playlistId, source),
uncollect: (playlistId: string, source = '') => ipcRenderer.invoke('playlist:uncollect', playlistId, source),
},
image: {

View File

@@ -2,11 +2,11 @@
<MainLayout />
<FullScreenPlayer />
<LoginDialog v-model:visible="showLoginDialog" />
<Settings v-if="showSettings" @close="showSettings = false" />
<Settings v-show="settingsVisible" v-if="showSettings" @close="showSettings = false" />
</template>
<script setup lang="ts">
import { ref, provide, onMounted, onBeforeUnmount } from 'vue';
import { ref, watch, provide, onMounted, onBeforeUnmount } from 'vue';
import { ElMessageBox } from 'element-plus';
import MainLayout from './layout/MainLayout.vue';
import Settings from './components/Settings.vue';
@@ -24,9 +24,27 @@ const playlistsStore = usePlaylistsStore();
const playerStore = usePlayerStore();
const together = useListenTogetherStore();
// 全屏播放时隐藏设置页,避免底层 -webkit-app-region 干扰播放页拖拽
// 延迟隐藏以等待播放页入场动画完成 (transform 0.46s)
const settingsVisible = ref(true);
let settingsHideTimer: number | undefined;
watch(() => playerStore.isPlayerFullScreen, (fullscreen) => {
if (fullscreen) {
clearTimeout(settingsHideTimer);
settingsHideTimer = window.setTimeout(() => {
settingsVisible.value = false;
}, 500);
} else {
clearTimeout(settingsHideTimer);
settingsVisible.value = true;
}
});
// Provide to child components
provide('openSettings', () => { showSettings.value = true; });
provide('openLoginDialog', () => { showLoginDialog.value = true; });
provide('isSettingsOpen', showSettings);
const isTypingTarget = (target: EventTarget | null) => {
const element = target as HTMLElement | null;
@@ -119,6 +137,7 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalShortcut);
window.removeEventListener('focus', checkClipboardInvite);
document.removeEventListener('visibilitychange', onVisibilityChange);
clearTimeout(settingsHideTimer);
});
</script>

View File

@@ -23,6 +23,10 @@
<Icon icon="lucide:heart" />
<span>我喜欢的</span>
</router-link>
<router-link to="/favorite-playlists" class="nav-item" active-class="active">
<Icon icon="lucide:bookmark" />
<span>收藏歌单</span>
</router-link>
<router-link to="/recent" class="nav-item" active-class="active">
<Icon icon="lucide:clock-3" />
<span>最近播放</span>

View File

@@ -1,5 +1,5 @@
<template>
<header class="topbar">
<header class="topbar" :class="{ 'no-drag': isDragDisabled }">
<div class="left-controls">
<div class="nav-group">
<button class="nav-btn" @click="goBack" title="返回">
@@ -78,19 +78,25 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from 'vue'
import { ref, computed, onMounted, onUnmounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../stores/auth'
import { usePlayerStore } from '../stores/player'
const router = useRouter()
const authStore = useAuthStore()
const playerStore = usePlayerStore()
const isMaximized = ref(false)
const searchQuery = ref('')
const showUserMenu = ref(false)
const ignoreNextUserClick = ref(false)
let userPressTimer: number | undefined
// Disable drag region when overlays are active to prevent conflicts
const settingsOpen = inject<import('vue').Ref<boolean>>('isSettingsOpen', ref(false))
const isDragDisabled = computed(() => playerStore.isPlayerFullScreen || settingsOpen.value)
const goBack = () => router.back()
const goForward = () => router.forward()
@@ -205,6 +211,10 @@ onUnmounted(() => {
backdrop-filter: none;
}
.topbar.no-drag {
-webkit-app-region: no-drag;
}
.left-controls,
.right-controls,
.nav-group,

View File

@@ -25,6 +25,11 @@ const router = createRouter({
name: 'Liked',
component: () => import('./views/Playlist.vue')
},
{
path: '/favorite-playlists',
name: 'FavoritePlaylists',
component: () => import('./views/FavoritePlaylists.vue')
},
{
path: '/recent',
name: 'Recent',

View File

@@ -276,6 +276,11 @@ export const usePlayerStore = defineStore('player', () => {
return;
}
loadingSongKey = songKey;
// Reset retry count only when switching to a genuinely different song
const prevSongKey = currentSong.value ? `${currentSong.value.source}:${currentSong.value.id}` : null;
if (prevSongKey !== songKey) {
currentSongRetryCount.value = 0;
}
try {
console.log(song);
currentSong.value = song;
@@ -314,7 +319,6 @@ export const usePlayerStore = defineStore('player', () => {
syncDummyAudioState(false);
}
song.url = playUrl;
currentSongRetryCount.value = 0;
// Record to recent plays (fire-and-forget)
recordRecentSong(song);
playErrorCount.value = 0;

View File

@@ -15,7 +15,7 @@ export interface PlaylistInfo {
author?: string
play_count?: string
visit_count?: number
like_count?: number
collection_count?: number
is_public?: boolean
}

View File

@@ -86,7 +86,7 @@ export interface IElectronAPI {
playlist: {
list: () => Promise<{ local: AppPlaylist[]; cloud: AppPlaylist[]; items: AppPlaylist[] }>;
publicList: (search?: string, sort?: string, page?: number, limit?: number) => Promise<{ items: AppPlaylist[]; total: number; page: number; limit: number; sort: string }>;
get: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
get: (scope: ManagedPlaylistScope, id: string, recordVisit?: boolean) => Promise<AppPlaylist>;
create: (scope: ManagedPlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => Promise<AppPlaylist>;
update: (scope: ManagedPlaylistScope, id: string, info: Partial<PlaylistInfo>) => Promise<AppPlaylist>;
delete: (scope: ManagedPlaylistScope, id: string) => Promise<{ success: boolean }>;
@@ -96,8 +96,9 @@ export interface IElectronAPI {
import: () => Promise<{ success: boolean; canceled?: boolean; playlist?: AppPlaylist }>;
convertScope: (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => Promise<AppPlaylist>;
copyToLocal: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
toggleLike: (playlistId: string) => Promise<{ status: string; liked: boolean; like_count: number }>;
getLike: (playlistId: string) => Promise<{ status: string; liked: boolean; like_count: number }>;
getFavorites: () => Promise<Array<{ id: string; source: string }>>;
collect: (playlistId: string, source?: string) => Promise<{ status: string; collected: boolean; collection_count?: number | null; message?: string | null }>;
uncollect: (playlistId: string, source?: string) => Promise<{ status: string; collected: boolean; collection_count?: number | null; message?: string | null }>;
};
image: {
selectAndUpload: () => Promise<{ success: boolean; canceled?: boolean; url?: string; message?: string }>;
@@ -205,7 +206,7 @@ export interface PlaylistInfo {
author?: string;
play_count?: string;
visit_count?: number;
like_count?: number;
collection_count?: number;
is_public?: boolean;
}

View File

@@ -0,0 +1,293 @@
<template>
<div class="favorite-playlists-view">
<div class="content-wrapper">
<section class="page-header">
<div>
<div class="eyebrow">COLLECTIONS</div>
<h1>收藏歌单</h1>
<p>插件与云端歌单会同步到你的账号</p>
</div>
<button class="refresh-btn" :disabled="loading || !authStore.isLoggedIn" @click="loadFavorites">
<Icon :icon="loading ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loading }" />
刷新
</button>
</section>
<div v-if="!authStore.isLoggedIn" class="empty-state">
<Icon icon="lucide:log-in" />
<span>登录后可同步收藏歌单</span>
</div>
<div v-else-if="loading" class="playlist-grid">
<div v-for="index in 8" :key="index" class="playlist-card skeleton"></div>
</div>
<div v-else-if="favorites.length === 0" class="empty-state">
<Icon icon="lucide:bookmark" />
<span>还没有收藏歌单</span>
</div>
<div v-else class="playlist-grid">
<article v-for="item in favorites" :key="`${item.source}:${item.id}`" class="playlist-card">
<button class="card-main" @click="openPlaylist(item)">
<div class="cover">
<img v-if="item.playlist?.info.img" :src="item.playlist.info.img" alt="" />
<Icon v-else :icon="item.source ? 'lucide:radio' : 'lucide:cloud'" />
</div>
<div class="card-copy">
<h2>{{ item.playlist?.info.name || '不可用的收藏歌单' }}</h2>
<p>{{ item.playlist?.info.desc || (item.source ? `插件 ${item.source} 暂不可用` : '云端歌单暂不可用') }}</p>
<span>{{ item.source ? `插件 · ${item.source}` : '云端歌单' }}</span>
</div>
</button>
<button class="remove-btn" title="取消收藏" @click="removeFavorite(item)">
<Icon icon="lucide:bookmark-minus" />
</button>
</article>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth'
import type { AppPlaylist } from '../stores/playlists'
interface FavoritePlaylistItem {
id: string
source: string
playlist: AppPlaylist | null
}
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const favorites = ref<FavoritePlaylistItem[]>([])
const resolveFavorite = async (ref: { id: string; source: string }): Promise<FavoritePlaylistItem> => {
try {
const playlist = ref.source
? await window.electronAPI.plugin.getPlaylist(ref.source, ref.id, 1, 1)
: await window.electronAPI.playlist.get('cloud', ref.id, false)
return { ...ref, playlist }
} catch {
return { ...ref, playlist: null }
}
}
const loadFavorites = async () => {
if (!authStore.isLoggedIn) {
favorites.value = []
return
}
loading.value = true
try {
const refs = await window.electronAPI.playlist.getFavorites()
favorites.value = await Promise.all(refs.map(resolveFavorite))
} catch (err: any) {
favorites.value = []
ElMessage.error(err?.message || '收藏歌单加载失败')
} finally {
loading.value = false
}
}
const openPlaylist = (item: FavoritePlaylistItem) => {
if (item.source) {
router.push({
name: 'PluginCollection',
params: { pluginId: item.source, kind: 'playlist', id: item.id },
})
} else {
router.push({ name: 'PlaylistDetail', params: { scope: 'cloud', id: item.id } })
}
}
const removeFavorite = async (item: FavoritePlaylistItem) => {
try {
const result = await window.electronAPI.playlist.uncollect(item.id, item.source)
if (result.status !== 'success') throw new Error(result.message || '取消收藏失败')
favorites.value = favorites.value.filter((current) =>
current.id !== item.id || current.source !== item.source
)
ElMessage.success('已取消收藏')
} catch (err: any) {
ElMessage.error(err?.message || '取消收藏失败')
}
}
watch(() => authStore.state.userInfo?.id, loadFavorites, { immediate: true })
</script>
<style scoped>
.favorite-playlists-view {
min-height: 100%;
}
.content-wrapper {
box-sizing: border-box;
max-width: 1180px;
margin: 0 auto;
padding: 34px 32px 48px;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 28px;
}
.eyebrow {
color: var(--color-accent);
font-size: 11px;
font-weight: 760;
letter-spacing: 0.16em;
}
h1 {
margin: 6px 0 8px;
font-size: 30px;
}
.page-header p,
.card-copy p {
margin: 0;
color: var(--color-text-muted);
}
.refresh-btn {
min-height: 38px;
padding: 0 15px;
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--color-accent-soft);
color: var(--color-accent);
}
.refresh-btn:disabled {
opacity: 0.45;
}
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 16px;
}
.playlist-card {
min-height: 118px;
display: flex;
align-items: stretch;
border-radius: 22px;
overflow: hidden;
background: color-mix(in srgb, var(--color-bg-secondary) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.card-main {
min-width: 0;
flex: 1;
padding: 14px;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
}
.cover {
width: 82px;
height: 82px;
flex-shrink: 0;
border-radius: 17px;
overflow: hidden;
display: grid;
place-items: center;
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover svg {
width: 26px;
height: 26px;
}
.card-copy {
min-width: 0;
}
.card-copy h2,
.card-copy p,
.card-copy span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.card-copy h2 {
margin: 0 0 8px;
font-size: 15px;
}
.card-copy p {
font-size: 12px;
}
.card-copy span {
display: block;
margin-top: 9px;
color: var(--color-text-secondary);
font-size: 11px;
}
.remove-btn {
width: 42px;
color: var(--color-text-muted);
}
.remove-btn:hover {
color: var(--color-danger, #ef4444);
background: color-mix(in srgb, #ef4444 8%, transparent);
}
.empty-state {
min-height: 260px;
display: grid;
place-items: center;
align-content: center;
gap: 12px;
color: var(--color-text-muted);
}
.empty-state svg {
width: 34px;
height: 34px;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite alternate;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes pulse {
from { opacity: 0.45; }
to { opacity: 0.85; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -2,10 +2,10 @@
<div class="playlist-view">
<div class="content-wrapper">
<section class="playlist-hero">
<div class="cover" :class="{ editable: isManagedPlaylist }" @click="isManagedPlaylist && openCoverDialog()">
<div class="cover" :class="{ editable: isEditablePlaylist }" @click="isEditablePlaylist && openCoverDialog()">
<img v-if="playlist?.info.img" :src="playlist.info.img" alt="" />
<Icon v-else :icon="heroIcon" />
<button v-if="isManagedPlaylist" class="cover-edit" title="设置封面" @click.stop="openCoverDialog">
<button v-if="isEditablePlaylist" class="cover-edit" title="设置封面" @click.stop="openCoverDialog">
<Icon icon="lucide:image-up" />
</button>
</div>
@@ -33,9 +33,9 @@
</router-link>
<span v-else-if="playlist?.info.author">{{ playlist.info.author }}</span>
<span v-if="isCloudPlaylist">访问 {{ accessCount }} </span>
<span v-if="isCloudPlaylist && likeCount >= 0">
<Icon icon="lucide:heart" style="width:13px;height:13px;vertical-align:-1px" />
{{ likeCount }}
<span v-if="isCloudPlaylist && collectionCount >= 0">
<Icon icon="lucide:bookmark" style="width:13px;height:13px;vertical-align:-1px" />
{{ collectionCount }}
</span>
</div>
<div class="hero-actions">
@@ -43,7 +43,7 @@
<Icon icon="lucide:play" />
播放全部
</button>
<button v-if="isManagedPlaylist" class="soft-btn" @click="openEditDialog">
<button v-if="isEditablePlaylist" class="soft-btn" @click="openEditDialog">
<Icon icon="lucide:pencil" />
编辑
</button>
@@ -55,26 +55,26 @@
<Icon icon="lucide:hard-drive-download" />
另存本地
</button>
<button v-if="isCloudPlaylist" class="soft-btn" @click="openCoverDialog">
<button v-if="isCloudPlaylist && isEditablePlaylist" class="soft-btn" @click="openCoverDialog">
<Icon icon="lucide:image-up" />
封面
</button>
<button v-else-if="isManagedPlaylist" class="soft-btn" @click="convertPlaylistMode">
<button v-else-if="isEditablePlaylist" class="soft-btn" @click="convertPlaylistMode">
<Icon :icon="targetScope === 'cloud' ? 'lucide:cloud-upload' : 'lucide:hard-drive-download'" />
{{ targetScope === 'cloud' ? '转为云端' : '转为本地' }}
</button>
<button v-if="isManagedPlaylist" class="icon-btn danger" title="删除歌单" @click="deletePlaylist">
<button v-if="isEditablePlaylist" class="icon-btn danger" title="删除歌单" @click="deletePlaylist">
<Icon icon="lucide:trash-2" />
</button>
<button
v-if="showLikeButton"
class="icon-btn like-btn"
:class="{ liked: isLikedByUser }"
:title="isLikedByUser ? '取消喜欢' : '喜欢'"
:disabled="likingPlaylist"
@click="toggleLikePlaylist"
v-if="showCollectButton"
class="icon-btn collect-btn"
:class="{ collected: isCollectedByUser }"
:title="isCollectedByUser ? '取消收藏' : '收藏'"
:disabled="collectingPlaylist"
@click="toggleCollectPlaylist"
>
<Icon :icon="isLikedByUser ? 'lucide:heart' : 'lucide:heart'" />
<Icon icon="lucide:bookmark" />
</button>
</div>
</div>
@@ -117,7 +117,7 @@
:key="`${song.source}:${song.id}:${index}`"
:song="song"
:display-index="displayStartIndex + index + 1"
:removable="isManagedPlaylist"
:removable="isEditablePlaylist"
reserve-action
@play="playSong(index)"
@remove="removeSong(index)"
@@ -238,10 +238,10 @@ const loadMoreTrigger = ref<HTMLElement | null>(null)
const pageSize = 50
let loadMoreObserver: IntersectionObserver | null = null
// Like state
const isLikedByUser = ref(false)
const likeCount = ref(-1)
const likingPlaylist = ref(false)
// Playlist collection state
const isCollectedByUser = ref(false)
const collectionCount = ref(-1)
const collectingPlaylist = ref(false)
type PublicSong = Partial<Song> & {
// 兼容云端 API 可能返回的旧字段名
@@ -270,17 +270,20 @@ const isOwnPlaylist = computed(() => {
if (!authStore.state.userInfo?.id || !playlist.value?.owner) return false
return authStore.state.userInfo.id === playlist.value.owner.id
})
const isEditablePlaylist = computed(() =>
routeScope.value === 'local' || (isCloudPlaylist.value && isOwnPlaylist.value)
)
const showAuthorLink = computed(() =>
isCloudPlaylist.value &&
!isOwnPlaylist.value &&
playlist.value?.info.is_public &&
playlist.value?.owner
)
const showLikeButton = computed(() =>
isCloudPlaylist.value &&
!isOwnPlaylist.value &&
authStore.isLoggedIn
)
const collectionSource = computed(() => isCloudPlaylist.value ? '' : String(routePluginId.value || ''))
const showCollectButton = computed(() => authStore.isLoggedIn && Boolean(
(isCloudPlaylist.value && routeId.value) ||
(isPluginCollection.value && routeKind.value === 'playlist' && routePluginId.value && routeId.value)
))
const targetScope = computed<ManagedPlaylistScope>(() => routeScope.value === 'local' ? 'cloud' : 'local')
const songCount = computed(() => playlist.value?.total ?? playlist.value?.list.length ?? 0)
const accessCount = computed(() => Number(playlist.value?.info.visit_count ?? playlist.value?.info.play_count ?? 0) || 0)
@@ -433,8 +436,8 @@ const loadPlaylist = async () => {
descriptionExpanded.value = false
currentPage.value = 1
loadedPage.value = 1
isLikedByUser.value = false
likeCount.value = -1
isCollectedByUser.value = false
collectionCount.value = -1
if (isLiked.value) {
loading.value = true
try {
@@ -470,6 +473,7 @@ const loadPlaylist = async () => {
try {
await loadPageMode()
await fetchPluginCollectionPage(1, false)
await loadCollectionState()
} catch (err: any) {
playlist.value = null
errorMessage.value = getErrorMessage(err)
@@ -488,7 +492,7 @@ const loadPlaylist = async () => {
try {
await loadPageMode()
playlist.value = await playlistStore.get(routeScope.value as ManagedPlaylistScope, routeId.value)
if (isCloudPlaylist.value) await loadLikeState()
if (isCloudPlaylist.value) await loadCollectionState()
} catch (err: any) {
playlist.value = null
errorMessage.value = getErrorMessage(err)
@@ -603,35 +607,51 @@ const removeSong = async (index: number) => {
playlist.value = await playlistStore.removeSong(routeScope.value, routeId.value, displayStartIndex.value + index)
}
// === Like Feature ===
// === Playlist Collection Feature ===
const loadLikeState = async () => {
if (!isCloudPlaylist.value || !routeId.value) {
isLikedByUser.value = false
likeCount.value = -1
const loadCollectionState = async () => {
if (!showCollectButton.value || !routeId.value) {
isCollectedByUser.value = false
collectionCount.value = isCloudPlaylist.value
? Number(playlist.value?.info.collection_count ?? 0) || 0
: -1
return
}
try {
const result = await window.electronAPI.playlist.getLike(routeId.value)
isLikedByUser.value = result.liked
likeCount.value = result.like_count
const favorites = await window.electronAPI.playlist.getFavorites()
const source = collectionSource.value
isCollectedByUser.value = favorites.some((item) =>
String(item.id) === routeId.value && String(item.source || '') === source
)
collectionCount.value = isCloudPlaylist.value
? Number(playlist.value?.info.collection_count ?? 0) || 0
: -1
} catch {
isLikedByUser.value = false
likeCount.value = -1
isCollectedByUser.value = false
collectionCount.value = isCloudPlaylist.value
? Number(playlist.value?.info.collection_count ?? 0) || 0
: -1
}
}
const toggleLikePlaylist = async () => {
if (!routeId.value || likingPlaylist.value) return
likingPlaylist.value = true
const toggleCollectPlaylist = async () => {
if (!routeId.value || collectingPlaylist.value || !showCollectButton.value) return
collectingPlaylist.value = true
try {
const result = await window.electronAPI.playlist.toggleLike(routeId.value)
isLikedByUser.value = result.liked
likeCount.value = result.like_count
const result = isCollectedByUser.value
? await window.electronAPI.playlist.uncollect(routeId.value, collectionSource.value)
: await window.electronAPI.playlist.collect(routeId.value, collectionSource.value)
if (result.status !== 'success') throw new Error(result.message || '收藏同步失败')
isCollectedByUser.value = result.collected
if (isCloudPlaylist.value && result.collection_count != null) {
collectionCount.value = Number(result.collection_count) || 0
if (playlist.value) playlist.value.info.collection_count = collectionCount.value
}
ElMessage.success(result.collected ? '已收藏歌单' : '已取消收藏')
} catch (err: any) {
ElMessage.error(err?.message || '操作失败')
} finally {
likingPlaylist.value = false
collectingPlaylist.value = false
}
}
@@ -884,19 +904,19 @@ h1 {
text-decoration: underline;
}
.icon-btn.like-btn {
.icon-btn.collect-btn {
color: var(--color-text-muted);
}
.icon-btn.like-btn:hover {
.icon-btn.collect-btn:hover {
color: #ff6b8a;
}
.icon-btn.like-btn.liked {
.icon-btn.collect-btn.collected {
color: #ff4d6d;
}
.icon-btn.like-btn.liked svg {
.icon-btn.collect-btn.collected svg {
fill: currentColor;
}

View File

@@ -16,9 +16,9 @@
<Icon icon="lucide:trending-up" />
访问量
</button>
<button :class="{ active: sort === 'like' }" @click="setSort('like')">
<Icon icon="lucide:heart" />
喜欢
<button :class="{ active: sort === 'collection' }" @click="setSort('collection')">
<Icon icon="lucide:bookmark" />
收藏
</button>
<button :class="{ active: sort === 'total' }" @click="setSort('total')">
<Icon icon="lucide:list-music" />
@@ -66,8 +66,8 @@
{{ Number(playlist.info.visit_count || playlist.info.play_count || 0) || 0 }}
</span>
<span>
<Icon icon="lucide:heart" />
{{ Number(playlist.info.like_count || 0) || 0 }}
<Icon icon="lucide:bookmark" />
{{ Number(playlist.info.collection_count || 0) || 0 }}
</span>
<span>
<Icon icon="lucide:music" />
@@ -103,7 +103,7 @@ import { usePlaylistsStore, type AppPlaylist } from '../stores/playlists'
const router = useRouter()
const playlistStore = usePlaylistsStore()
const query = ref('')
const sort = ref<'visit' | 'like' | 'total' | 'name'>('visit')
const sort = ref<'visit' | 'collection' | 'total' | 'name'>('visit')
const playlists = ref<AppPlaylist[]>([])
const total = ref(0)
const page = ref(1)
@@ -117,7 +117,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
const sortLabel = computed(() => {
if (sort.value === 'total') return '歌曲数'
if (sort.value === 'name') return '名称'
if (sort.value === 'like') return '喜欢数'
if (sort.value === 'collection') return '收藏数'
return '访问量'
})
@@ -152,7 +152,7 @@ watch(query, () => {
watch(page, () => scheduleLoad(120))
const setSort = (nextSort: 'visit' | 'like' | 'total' | 'name') => {
const setSort = (nextSort: 'visit' | 'collection' | 'total' | 'name') => {
if (sort.value === nextSort) return
sort.value = nextSort
page.value = 1

File diff suppressed because one or more lines are too long