mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-08-18 15:33:27 +08:00
feat: 实现收藏歌单、修复TopBar拖拽穿透问题、适配Linux amd64
This commit is contained in:
126
.github/workflows/build.yml
vendored
Normal file
126
.github/workflows/build.yml
vendored
Normal 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
103
CLAUDE.md
Normal 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
|
||||
46
README.md
46
README.md
@@ -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
BIN
core/libfftw3f.so.3
Normal file
Binary file not shown.
BIN
core/qzplayer
Normal file
BIN
core/qzplayer
Normal file
Binary file not shown.
25
native/taglib_reader/build.sh
Normal file
25
native/taglib_reader/build.sh
Normal 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"
|
||||
@@ -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
|
||||
|
||||
55
package.json
55
package.json
@@ -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
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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.
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PlaylistInfo {
|
||||
author?: string
|
||||
play_count?: string
|
||||
visit_count?: number
|
||||
like_count?: number
|
||||
collection_count?: number
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
|
||||
9
src/renderer/src/types/electron.d.ts
vendored
9
src/renderer/src/types/electron.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
293
src/renderer/src/views/FavoritePlaylists.vue
Normal file
293
src/renderer/src/views/FavoritePlaylists.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user