1 Commits

Author SHA1 Message Date
lqtmcstudio
0af8c4f953 feat(chore): 重构UI、实现功能
- 用户登录(支持扫码登录)
- 一起听
- 听歌足迹
- 排行榜
- 本地音乐
- 快捷键
- 云歌单/本地歌单
- 歌单导入导出
- 喜欢
2026-06-20 22:05:12 +08:00
45 changed files with 11420 additions and 1670 deletions

View File

@@ -0,0 +1,54 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $root "..\..")
$outDir = Join-Path $root "build"
$cliObj = Join-Path $outDir "taglib_reader_cli.obj"
$cliOut = Join-Path $outDir "taglib_reader_cli.exe"
$taglibRoot = "D:\1Music\taglib\taglib\build-win\install"
$taglibInclude = Join-Path $taglibRoot "include"
$utfcppInclude = Join-Path $taglibRoot "include\utf8cpp"
$taglibLib = Join-Path $taglibRoot "lib\tag.lib"
foreach ($path in @($taglibInclude, $taglibLib)) {
if (!(Test-Path $path)) {
throw "Missing dependency: $path"
}
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$vsDevCmd = "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"
$cliClArgs = @(
"/nologo",
"/std:c++17",
"/EHsc",
"/MD",
"/O2",
"/DTAGLIB_STATIC",
"/I`"$taglibInclude`"",
"/I`"$utfcppInclude`"",
"/c",
"`"$(Join-Path $root "taglib_reader_cli.cpp")`"",
"/Fo`"$cliObj`""
) -join " "
$cliLinkArgs = @(
"/nologo",
"/OUT:`"$cliOut`"",
"`"$cliObj`"",
"`"$taglibLib`"",
"Advapi32.lib",
"Shell32.lib",
"Ole32.lib",
"User32.lib"
) -join " "
$command = "`"$vsDevCmd`" -arch=x64 && cl $cliClArgs && link $cliLinkArgs"
& $env:ComSpec /d /c $command
if ($LASTEXITCODE -ne 0) {
throw "Native build failed with exit code $LASTEXITCODE"
}
Write-Host "Built $cliOut"

Binary file not shown.

View File

@@ -0,0 +1,403 @@
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
#include <windows.h>
#include <taglib/attachedpictureframe.h>
#include <taglib/fileref.h>
#include <taglib/flacfile.h>
#include <taglib/flacpicture.h>
#include <taglib/id3v2tag.h>
#include <taglib/mpegfile.h>
#include <taglib/mp4coverart.h>
#include <taglib/mp4file.h>
#include <taglib/mp4tag.h>
#include <taglib/oggfile.h>
#include <taglib/tbytevector.h>
#include <taglib/tpropertymap.h>
#include <taglib/unsynchronizedlyricsframe.h>
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/xiphcomment.h>
namespace fs = std::filesystem;
namespace {
constexpr unsigned int kMaxCoverBytes = 8 * 1024 * 1024;
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);
if (size <= 0) return "";
std::string result(size, '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
return result;
}
std::wstring Utf8ToWide(const std::string &value) {
if (value.empty()) return L"";
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) return L"";
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string ToUtf8(const TagLib::String &value) {
return value.to8Bit(true);
}
std::string Trim(const std::string &value) {
const auto start = value.find_first_not_of(" \t\r\n");
if (start == std::string::npos) return "";
const auto end = value.find_last_not_of(" \t\r\n");
return value.substr(start, end - start + 1);
}
std::string JsonEscape(const std::string &value) {
std::ostringstream stream;
for (const unsigned char ch : value) {
switch (ch) {
case '\"': stream << "\\\""; break;
case '\\': stream << "\\\\"; break;
case '\b': stream << "\\b"; break;
case '\f': stream << "\\f"; break;
case '\n': stream << "\\n"; break;
case '\r': stream << "\\r"; break;
case '\t': stream << "\\t"; break;
default:
if (ch < 0x20) {
stream << "\\u";
const char *hex = "0123456789abcdef";
stream << "00" << hex[(ch >> 4) & 0x0F] << hex[ch & 0x0F];
} else {
stream << ch;
}
}
}
return stream.str();
}
std::string Q(const std::string &value) {
return "\"" + JsonEscape(value) + "\"";
}
std::string DetectMimeType(const TagLib::ByteVector &data) {
if (data.size() >= 4) {
const auto *d = reinterpret_cast<const unsigned char *>(data.data());
if (d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) return "image/jpeg";
if (d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47) return "image/png";
if (d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38) return "image/gif";
if (data.size() >= 12 && d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50) {
return "image/webp";
}
}
return "image/jpeg";
}
std::string CoverExtension(const std::string &mime) {
if (mime == "image/png") return ".png";
if (mime == "image/gif") return ".gif";
if (mime == "image/webp") return ".webp";
return ".jpg";
}
std::string HexHash(const std::string &value) {
uint64_t hash = 1469598103934665603ull;
for (const unsigned char ch : value) {
hash ^= ch;
hash *= 1099511628211ull;
}
std::ostringstream stream;
stream << std::hex << std::setw(16) << std::setfill('0') << hash;
return stream.str();
}
std::string WriteCoverFile(
const TagLib::ByteVector &cover,
const fs::path &filePath,
const fs::path &artworkDir,
const std::string &mime) {
if (cover.isEmpty() || cover.size() > kMaxCoverBytes || artworkDir.empty()) return "";
std::error_code ec;
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)));
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());
}
std::string Base64Encode(const TagLib::ByteVector &data) {
static constexpr char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string output;
output.reserve(((data.size() + 2) / 3) * 4);
const auto *bytes = reinterpret_cast<const unsigned char *>(data.data());
for (unsigned int i = 0; i < data.size(); i += 3) {
const unsigned int b0 = bytes[i];
const unsigned int b1 = i + 1 < data.size() ? bytes[i + 1] : 0;
const unsigned int b2 = i + 2 < data.size() ? bytes[i + 2] : 0;
output.push_back(table[(b0 >> 2) & 0x3F]);
output.push_back(table[((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0F)]);
output.push_back(i + 1 < data.size() ? table[((b1 & 0x0F) << 2) | ((b2 >> 6) & 0x03)] : '=');
output.push_back(i + 2 < data.size() ? table[b2 & 0x3F] : '=');
}
return output;
}
std::string FormatDuration(int seconds) {
if (seconds <= 0) return "00:00";
const int minutes = seconds / 60;
const int rest = seconds % 60;
std::ostringstream stream;
stream << minutes << ':';
if (rest < 10) stream << '0';
stream << rest;
return stream.str();
}
std::string BuildQuality(TagLib::File *file, int bitrate, int sampleRate) {
if (auto *flac = dynamic_cast<TagLib::FLAC::File *>(file)) {
if (flac->audioProperties()) {
std::ostringstream stream;
stream << (sampleRate / 1000.0) << "kHz " << flac->audioProperties()->bitsPerSample() << "bit";
return stream.str();
}
}
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
if (wav->audioProperties()) {
std::ostringstream stream;
stream << (sampleRate / 1000.0) << "kHz " << wav->audioProperties()->bitsPerSample() << "bit";
return stream.str();
}
}
if (bitrate > 0) return std::to_string(bitrate) + "kbps";
return "";
}
TagLib::ByteVector ReadCover(TagLib::File *file) {
if (auto *mp3 = dynamic_cast<TagLib::MPEG::File *>(file)) {
if (auto *id3 = mp3->ID3v2Tag()) {
const auto frames = id3->frameList("APIC");
if (!frames.isEmpty()) {
if (auto *frame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frames.front())) return frame->picture();
}
}
}
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
if (auto *id3 = wav->ID3v2Tag()) {
const auto frames = id3->frameList("APIC");
if (!frames.isEmpty()) {
if (auto *frame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frames.front())) return frame->picture();
}
}
}
if (auto *flac = dynamic_cast<TagLib::FLAC::File *>(file)) {
const auto pictures = flac->pictureList();
if (!pictures.isEmpty()) return pictures.front()->data();
}
if (auto *mp4 = dynamic_cast<TagLib::MP4::File *>(file)) {
if (auto *tag = mp4->tag(); tag && tag->contains("covr")) {
const auto covers = tag->item("covr").toCoverArtList();
if (!covers.isEmpty()) return covers.front().data();
}
}
if (auto *vorbis = dynamic_cast<TagLib::Ogg::Vorbis::File *>(file)) {
if (auto *tag = vorbis->tag()) {
const auto pictures = tag->pictureList();
if (!pictures.isEmpty()) return pictures.front()->data();
}
}
return {};
}
std::string FirstProperty(TagLib::File *file, const std::vector<std::string> &keys) {
if (!file) return "";
const auto properties = file->properties();
for (const auto &rawKey : keys) {
const TagLib::String key(rawKey, TagLib::String::UTF8);
if (!properties.contains(key)) continue;
const auto values = properties[key];
if (!values.isEmpty()) {
const auto text = Trim(ToUtf8(values.front()));
if (!text.empty()) return text;
}
}
return "";
}
std::string ReadLyrics(TagLib::File *file) {
if (!file) return "";
if (auto *mp3 = dynamic_cast<TagLib::MPEG::File *>(file)) {
if (auto *id3 = mp3->ID3v2Tag()) {
const auto frames = id3->frameList("USLT");
for (auto *rawFrame : frames) {
if (auto *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(rawFrame)) {
const auto text = Trim(ToUtf8(frame->text()));
if (!text.empty()) return text;
}
}
}
}
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
if (auto *id3 = wav->ID3v2Tag()) {
const auto frames = id3->frameList("USLT");
for (auto *rawFrame : frames) {
if (auto *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(rawFrame)) {
const auto text = Trim(ToUtf8(frame->text()));
if (!text.empty()) return text;
}
}
}
}
return FirstProperty(file, {
"LYRICS",
"UNSYNCEDLYRICS",
"UNSYNCHRONIZEDLYRICS",
});
}
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 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);
if (fileRef.isNull() || !fileRef.file()) {
throw std::runtime_error("Unsupported or unreadable audio file");
}
auto *tag = fileRef.tag();
auto *file = fileRef.file();
auto *props = fileRef.audioProperties();
const int seconds = props ? props->lengthInSeconds() : 0;
const int bitrate = props ? props->bitrate() : 0;
const int sampleRate = props ? props->sampleRate() : 0;
const int channels = props ? props->channels() : 0;
const auto cover = ReadCover(file);
const bool includeCover = !cover.isEmpty() && cover.size() <= kMaxCoverBytes;
const auto mime = includeCover ? DetectMimeType(cover) : "";
const auto coverPath = includeCover ? WriteCoverFile(cover, filePath, artworkDir, mime) : "";
const auto lyric = ReadLyrics(file);
std::ostringstream json;
json << "{";
json << "\"path\":" << Q(WideToUtf8(filePath.wstring())) << ",";
json << "\"title\":" << Q(tag ? ToUtf8(tag->title()) : "") << ",";
json << "\"artist\":" << Q(tag ? ToUtf8(tag->artist()) : "") << ",";
json << "\"album\":" << Q(tag ? ToUtf8(tag->album()) : "") << ",";
json << "\"comment\":" << Q(tag ? ToUtf8(tag->comment()) : "") << ",";
json << "\"genre\":" << Q(tag ? ToUtf8(tag->genre()) : "") << ",";
json << "\"year\":" << (tag ? tag->year() : 0) << ",";
json << "\"track\":" << (tag ? tag->track() : 0) << ",";
json << "\"durationSeconds\":" << seconds << ",";
json << "\"duration\":" << Q(FormatDuration(seconds)) << ",";
json << "\"bitrate\":" << bitrate << ",";
json << "\"sampleRate\":" << sampleRate << ",";
json << "\"channels\":" << channels << ",";
json << "\"quality\":" << Q(BuildQuality(file, bitrate, sampleRate)) << ",";
json << "\"coverMime\":" << Q(mime) << ",";
json << "\"coverPath\":" << Q(coverPath) << ",";
json << "\"coverDataUrl\":" << Q("") << ",";
json << "\"lyric\":" << Q(lyric);
json << "}";
return json.str();
}
std::vector<fs::path> CollectAudioFiles(const std::vector<fs::path> &roots) {
std::vector<fs::path> files;
for (const auto &root : roots) {
std::error_code ec;
if (!fs::exists(root, ec) || !fs::is_directory(root, ec)) continue;
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec);
fs::recursive_directory_iterator end;
while (it != end) {
if (!ec && it->is_regular_file(ec) && IsAudioPath(it->path())) files.push_back(it->path());
it.increment(ec);
}
}
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";
return 2;
}
try {
const std::wstring mode = argv[1];
if (mode == L"read") {
std::cout << ReadFileJson(fs::path(argv[2]));
return 0;
}
if (mode == L"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]);
startIndex = 4;
}
for (int i = startIndex; i < argc; i++) roots.emplace_back(argv[i]);
const auto files = CollectAudioFiles(roots);
std::cout << "{\"songs\":[";
bool first = true;
for (const auto &file : files) {
try {
if (!first) std::cout << ",";
std::cout << ReadFileJson(file, artworkDir);
first = false;
} catch (const std::exception &error) {
std::cerr << "Failed to read " << WideToUtf8(file.wstring()) << ": " << error.what() << "\n";
}
}
std::cout << "]}";
return 0;
}
std::cerr << "Unknown mode\n";
return 2;
} catch (const std::exception &error) {
std::cerr << error.what() << "\n";
return 1;
}
}

217
package-lock.json generated
View File

@@ -21,11 +21,13 @@
"@pixi/filter-bulge-pinch": "^5.1.1", "@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3", "@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3", "@pixi/sprite": "^7.4.3",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue-jsx": "^5.1.5", "@vitejs/plugin-vue-jsx": "^5.1.5",
"element-plus": "^2.13.7", "element-plus": "^2.13.7",
"jss": "^10.10.0", "jss": "^10.10.0",
"jss-preset-default": "^10.10.0", "jss-preset-default": "^10.10.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"url": "^0.11.4", "url": "^0.11.4",
"vite-plugin-wasm": "^3.6.0", "vite-plugin-wasm": "^3.6.0",
"vue": "^3.5.33", "vue": "^3.5.33",
@@ -3762,6 +3764,15 @@
"xmlbuilder": ">=11.0.1" "xmlbuilder": ">=11.0.1"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/responselike": { "node_modules/@types/responselike": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
@@ -4391,7 +4402,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4401,7 +4411,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -5300,6 +5309,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001768", "version": "1.0.30001768",
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz",
@@ -5461,7 +5479,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -5474,7 +5491,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorjs.io": { "node_modules/colorjs.io": {
@@ -5908,6 +5924,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -6057,6 +6082,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": { "node_modules/dir-compare": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-3.3.0.tgz", "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-3.3.0.tgz",
@@ -7056,7 +7087,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/empathic": { "node_modules/empathic": {
@@ -7578,7 +7608,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -8157,7 +8186,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -9698,6 +9726,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -9740,7 +9777,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -9913,6 +9949,15 @@
"node": ">=10.4.0" "node": ">=10.4.0"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -10088,6 +10133,141 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.1",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz",
@@ -10224,12 +10404,17 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
@@ -11106,6 +11291,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11437,7 +11628,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -11468,7 +11658,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -13097,6 +13286,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",

View File

@@ -25,11 +25,13 @@
"@pixi/filter-bulge-pinch": "^5.1.1", "@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3", "@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3", "@pixi/sprite": "^7.4.3",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue-jsx": "^5.1.5", "@vitejs/plugin-vue-jsx": "^5.1.5",
"element-plus": "^2.13.7", "element-plus": "^2.13.7",
"jss": "^10.10.0", "jss": "^10.10.0",
"jss-preset-default": "^10.10.0", "jss-preset-default": "^10.10.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"url": "^0.11.4", "url": "^0.11.4",
"vite-plugin-wasm": "^3.6.0", "vite-plugin-wasm": "^3.6.0",
"vue": "^3.5.33", "vue": "^3.5.33",
@@ -72,6 +74,10 @@
{ {
"from": "core/libfftw3f-3.dll", "from": "core/libfftw3f-3.dll",
"to": "core/libfftw3f-3.dll" "to": "core/libfftw3f-3.dll"
},
{
"from": "native/taglib_reader/build/taglib_reader_cli.exe",
"to": "native/taglib_reader_cli.exe"
} }
] ]
} }

380
src/main/authStore.ts Normal file
View File

@@ -0,0 +1,380 @@
import { app, shell } from 'electron'
import fs from 'node:fs'
import crypto from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import QRCode from 'qrcode'
export const API_BASE_URL = 'https://api.qz.shiqianjiang.cn/app'
const AUTH_LOGIN_URL = `${API_BASE_URL}/auth/login`
const UPLOAD_ENDPOINT = 'https://picgo.re-link.top'
const UPLOAD_TOKEN_SECRET = 'pm9K2nBSseKihywiDH3hiaGJwzyTGQwj'
export interface UserInfo {
id: string
username: string
avatar?: string | null
nickname?: string | null
gender?: string | null
region?: string | null
intro?: string | null
birthday?: string | null
subscribing?: boolean
}
export interface AuthState {
accessToken: string
refreshToken: string
exp: number
userInfo: UserInfo | null
}
export interface AuthCallbackPayload {
status: string
message?: string
user_info?: UserInfo
access_token?: string
refresh_token?: string
exp?: number
}
export interface QrLoginSession {
status: string
session_id: string
poll_token: string
qr_payload: string
qr_data_url: string
expires_at: number
expires_in: number
message?: string
}
export interface QrLoginPollResult extends AuthCallbackPayload {
state?: AuthState
device_name?: string
expires_at?: number
}
const EMPTY_AUTH_STATE: AuthState = {
accessToken: '',
refreshToken: '',
exp: 0,
userInfo: null,
}
let authCache: AuthState | null = null
function getAuthPath(): string {
return path.join(app.getPath('userData'), 'auth.json')
}
function normalizeAuthPayload(payload: AuthCallbackPayload): AuthState {
const rawExp = Number(payload.exp) || 0
const exp = rawExp > 0 && rawExp < 10_000_000_000 ? rawExp * 1000 : rawExp
return {
accessToken: payload.access_token || '',
refreshToken: payload.refresh_token || '',
exp,
userInfo: payload.user_info || null,
}
}
export function loadAuthState(): AuthState {
if (authCache) return authCache
try {
const authPath = getAuthPath()
if (fs.existsSync(authPath)) {
const raw = JSON.parse(fs.readFileSync(authPath, 'utf8'))
const nextState = { ...EMPTY_AUTH_STATE, ...raw }
if (nextState.exp > 0 && nextState.exp < 10_000_000_000) {
nextState.exp *= 1000
}
authCache = nextState
return nextState
}
} catch (err) {
console.error('[Auth] Failed to load auth state:', err)
}
const nextState = { ...EMPTY_AUTH_STATE }
authCache = nextState
return nextState
}
export function saveAuthState(nextState: AuthState): AuthState {
authCache = { ...EMPTY_AUTH_STATE, ...nextState }
try {
fs.writeFileSync(getAuthPath(), JSON.stringify(authCache, null, 2), 'utf8')
} catch (err) {
console.error('[Auth] Failed to save auth state:', err)
}
return authCache
}
export function clearAuthState(): AuthState {
authCache = { ...EMPTY_AUTH_STATE }
try {
if (fs.existsSync(getAuthPath())) fs.unlinkSync(getAuthPath())
} catch (err) {
console.error('[Auth] Failed to clear auth state:', err)
}
return authCache
}
export async function openLoginPage(forcePrompt = false): Promise<{ success: boolean; url: string }> {
const url = forcePrompt ? `${AUTH_LOGIN_URL}?prompt=login` : AUTH_LOGIN_URL
await shell.openExternal(url)
return { success: true, url }
}
async function authQrFetch<T>(pathname: string, body: any): Promise<T> {
const resp = await fetch(`${API_BASE_URL}${pathname}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!resp.ok) {
const message = await resp.text().catch(() => '')
throw new Error(message || `Request failed: ${resp.status}`)
}
return resp.json() as Promise<T>
}
export async function createQrLoginSession(): Promise<QrLoginSession> {
const deviceName = `${app.getName() || 'QZMusic'} on ${os.hostname() || process.platform}`
const payload = await authQrFetch<Omit<QrLoginSession, 'qr_data_url'>>('/auth/qr/session', {
device_name: deviceName,
client: 'qzmusic-electron',
platform: process.platform,
})
if (payload.status !== 'success') {
throw new Error(payload.message || 'Create QR login session failed')
}
const qrDataUrl = await QRCode.toDataURL(payload.qr_payload, {
errorCorrectionLevel: 'M',
margin: 1,
width: 256,
})
return { ...payload, qr_data_url: qrDataUrl }
}
export async function pollQrLoginSession(sessionId: string, pollToken: string): Promise<QrLoginPollResult> {
const payload = await authQrFetch<QrLoginPollResult>('/auth/qr/poll', {
session_id: sessionId,
poll_token: pollToken,
})
if (payload.status === 'success' && payload.access_token) {
const state = acceptAuthCallback(payload)
return { ...payload, state }
}
return payload
}
export async function cancelQrLoginSession(sessionId: string, pollToken: string): Promise<any> {
return authQrFetch('/auth/qr/cancel', {
session_id: sessionId,
poll_token: pollToken,
})
}
export function acceptAuthCallback(payload: AuthCallbackPayload): AuthState {
if (payload.status !== 'success') {
throw new Error(payload.message || 'Login failed')
}
const nextState = normalizeAuthPayload(payload)
if (!nextState.accessToken || !nextState.refreshToken || !nextState.userInfo?.id) {
throw new Error('Invalid login response')
}
return saveAuthState(nextState)
}
export async function refreshAuthState(): Promise<AuthState> {
const state = loadAuthState()
if (!state.accessToken || !state.refreshToken) return state
const resp = await fetch(`${API_BASE_URL}/auth/refresh_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: state.accessToken,
refresh_token: state.refreshToken,
}),
})
if (!resp.ok) {
throw new Error(`Refresh token failed: ${resp.status}`)
}
const payload = await resp.json() as AuthCallbackPayload
if (payload.status !== 'success') {
throw new Error(payload.message || 'Login expired')
}
const refreshed = normalizeAuthPayload(payload)
return saveAuthState({
...state,
...refreshed,
userInfo: refreshed.userInfo || state.userInfo,
})
}
export async function getValidAccessToken(): Promise<string> {
const state = loadAuthState()
if (!state.accessToken) return ''
if (Date.now() > state.exp - 60_000) {
const refreshed = await refreshAuthState()
return refreshed.accessToken
}
return state.accessToken
}
export async function qzFetch(pathname: string, init: RequestInit = {}): Promise<any> {
const token = await getValidAccessToken()
const headers = new Headers(init.headers)
headers.set('Content-Type', headers.get('Content-Type') || 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
const resp = await fetch(`${API_BASE_URL}${pathname}`, {
...init,
headers,
})
if (!resp.ok) {
const message = await resp.text().catch(() => '')
throw new Error(message || `Request failed: ${resp.status}`)
}
if (resp.status === 204) return null
return resp.json()
}
function mimeForImage(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
if (ext === '.png') return 'image/png'
if (ext === '.webp') return 'image/webp'
if (ext === '.gif') return 'image/gif'
return 'image/jpeg'
}
function uploadToken(userId: string, timestamp: string): string {
return crypto
.createHmac('sha256', UPLOAD_TOKEN_SECRET)
.update(`${userId}.${timestamp}`)
.digest('hex')
}
export async function uploadImage(filePath: string): Promise<{ success: boolean; url?: string; message?: string }> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
if (!fs.existsSync(filePath)) throw new Error('Image file not found')
const timestamp = Math.floor(Date.now() / 1000).toString()
const form = new FormData()
form.append('userid', userId)
form.append('timestamp', timestamp)
form.append('token', uploadToken(userId, timestamp))
form.append(
'file',
new Blob([new Uint8Array(fs.readFileSync(filePath))], { type: mimeForImage(filePath) }),
path.basename(filePath),
)
const resp = await fetch(UPLOAD_ENDPOINT, {
method: 'POST',
headers: {
Origin: 'https://re-link.top',
},
body: form,
})
const text = await resp.text()
if (!resp.ok) {
throw new Error(text || `Upload failed: ${resp.status}`)
}
const data = JSON.parse(text)
if (!data?.ok) {
throw new Error(data?.message || 'Upload failed')
}
const url = String(data.url || data.display_url || '')
if (!url) throw new Error('Upload response missing url')
return { success: true, url }
}
export function getListenTogetherWsUrl(params: Record<string, string>): string {
const query = new URLSearchParams(params)
return `wss://interface.qz.folltoshe.com/ws?${query.toString()}`
}
export async function sendPcHeartbeat(duration: number, timestamp = Date.now()): Promise<any> {
return qzFetch('/heartbeat/pc', {
method: 'POST',
body: JSON.stringify({ duration, timestamp }),
})
}
export async function getListenTime(detail = 1, userId?: string): Promise<any> {
const state = loadAuthState()
const targetUserId = userId || state.userInfo?.id
if (!targetUserId) throw new Error('Not logged in')
return qzFetch(`/user/${encodeURIComponent(targetUserId)}/stat/listen/time?detail=${detail}`)
}
export async function getListenTimeRange(start: string, end: string, userId?: string): Promise<any> {
const state = loadAuthState()
const targetUserId = userId || state.userInfo?.id
if (!targetUserId) throw new Error('Not logged in')
const query = new URLSearchParams({ start, end })
return qzFetch(`/user/${encodeURIComponent(targetUserId)}/stat/listen/range?${query.toString()}`)
}
export async function getListenRank(period: 'week' | 'month' | 'year' = 'week', limit = 200): Promise<any> {
const query = new URLSearchParams({ period, limit: String(Math.max(1, Math.min(200, limit))) })
return qzFetch(`/user/stat/listen/rank?${query.toString()}`)
}
export async function getUserProfile(userId: string): Promise<UserInfo> {
if (!userId) throw new Error('Missing user id')
return qzFetch(`/user/${encodeURIComponent(userId)}/info`)
}
export async function getUserPublicPlaylists(userId: string): Promise<any[]> {
if (!userId) throw new Error('Missing user id')
const result = await qzFetch(`/user/${encodeURIComponent(userId)}/playlists`)
return Array.isArray(result) ? result : []
}
export async function getUserPublicFavSongs(userId: string): Promise<any[]> {
if (!userId) throw new Error('Missing user id')
const result = await qzFetch(`/user/${encodeURIComponent(userId)}/fav/songs`)
return Array.isArray(result) ? result : []
}
export async function updateCurrentUserProfile(payload: Partial<UserInfo>): Promise<UserInfo> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
await qzFetch(`/user/${encodeURIComponent(userId)}/info`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
const userInfo = await getUserProfile(userId)
saveAuthState({ ...state, userInfo })
return userInfo
}
export async function getLibraryPrivacy(): Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
return qzFetch(`/user/${encodeURIComponent(userId)}/privacy/library`)
}
export async function setLibraryPrivacy(payload: { allow_public_library?: boolean; allow_public_profile?: boolean }): Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }> {
const state = loadAuthState()
const userId = state.userInfo?.id
if (!userId) throw new Error('Not logged in')
return qzFetch(`/user/${encodeURIComponent(userId)}/privacy/library`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}

View File

@@ -7,6 +7,53 @@ import { QzpController } from './qzpController'
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow, refreshCacheDir } from './proxyServer' import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow, refreshCacheDir } from './proxyServer'
import { PluginSystem } from './pluginSystem' import { PluginSystem } from './pluginSystem'
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore' import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
import {
acceptAuthCallback,
cancelQrLoginSession,
clearAuthState,
createQrLoginSession,
getLibraryPrivacy,
getListenTogetherWsUrl,
getListenRank,
getValidAccessToken,
getListenTime,
getListenTimeRange,
getUserProfile,
getUserPublicFavSongs,
getUserPublicPlaylists,
loadAuthState,
openLoginPage,
pollQrLoginSession,
refreshAuthState,
sendPcHeartbeat,
setLibraryPrivacy,
updateCurrentUserProfile,
uploadImage,
type AuthCallbackPayload,
} from './authStore'
import {
addSong,
copyPlaylistToLocal,
convertPlaylistScope,
createPlaylist,
deletePlaylist as deleteStoredPlaylist,
exportPlaylist,
getPlaylist,
importPlaylist,
listCloudPlaylists,
listLocalPlaylists,
listPublicPlaylists,
removeSong,
updatePlaylist,
type PlaylistScope,
} from './playlistStore'
import {
clearMissingLocalSongs,
getLocalMusicLibrary,
removeLocalSong,
scanLocalMusic,
setLocalMusicRoots,
} from './localMusicStore'
// @ts-ignore // @ts-ignore
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -26,6 +73,65 @@ function notifyPluginsChanged(action: 'installed' | 'updated' | 'uninstalled', p
win?.webContents.send('plugin:changed', { action, pluginId }) win?.webContents.send('plugin:changed', { action, pluginId })
} }
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
}
function decodeAuthCallback(url: string): AuthCallbackPayload | null {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'qzmusic:' || parsed.hostname !== 'auth_result') return null
const hexData = parsed.searchParams.get('data')
if (!hexData) return null
const jsonText = Buffer.from(hexData, 'hex').toString('utf8')
return JSON.parse(jsonText)
} catch (err) {
console.error('[Auth] Failed to decode callback:', err)
return null
}
}
function handleAuthCallback(url: string): void {
const payload = decodeAuthCallback(url)
if (!payload) return
try {
const state = acceptAuthCallback(payload)
win?.webContents.send('auth:changed', { status: 'success', state })
} catch (err: any) {
win?.webContents.send('auth:changed', {
status: 'error',
message: err?.message || 'Login failed',
state: loadAuthState(),
})
}
if (win?.isMinimized()) win.restore()
win?.focus()
}
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('qzmusic', process.execPath, [path.resolve(process.argv[1])])
}
} else {
app.setAsDefaultProtocolClient('qzmusic')
}
app.on('second-instance', (_event, argv) => {
const callbackUrl = argv.find((arg) => arg.startsWith('qzmusic://auth_result'))
if (callbackUrl) handleAuthCallback(callbackUrl)
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.on('open-url', (event, url) => {
event.preventDefault()
handleAuthCallback(url)
})
// === Electron 窗口逻辑 === // === Electron 窗口逻辑 ===
function createWindow() { function createWindow() {
@@ -65,6 +171,16 @@ ipcMain.on('window-minimize', (event) => BrowserWindow.fromWebContents(event.sen
ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize()) ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize())
ipcMain.on('window-close', () => win?.close()) ipcMain.on('window-close', () => win?.close())
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false) ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
ipcMain.handle('window:setProgressBar', (_event, progress: number, mode: 'normal' | 'paused' = 'normal') => {
if (!win) return false
const value = Number(progress)
if (!Number.isFinite(value) || value < 0) {
win.setProgressBar(-1)
return true
}
win.setProgressBar(Math.max(0, Math.min(1, value)), { mode })
return true
})
// --- qzplayer IPC Handlers --- // --- qzplayer IPC Handlers ---
ipcMain.handle('qzplayer-command', async (_, command: any[]) => { ipcMain.handle('qzplayer-command', async (_, command: any[]) => {
@@ -102,6 +218,16 @@ ipcMain.handle('plugin:getAll', () => {
return PluginSystem.getAllPlugins() return PluginSystem.getAllPlugins()
}) })
ipcMain.handle('plugin:getPlaylist', async (_event, pluginId: string, id: string, page = 1, limit = 100) => {
const plugin = new PluginSystem(pluginId)
return plugin.getPlaylist(id, page, limit)
})
ipcMain.handle('plugin:getAlbum', async (_event, pluginId: string, id: string, page = 1, limit = 100) => {
const plugin = new PluginSystem(pluginId)
return plugin.getAlbum(id, page, limit)
})
ipcMain.handle('plugin:uninstall', (_, id: string) => { ipcMain.handle('plugin:uninstall', (_, id: string) => {
const success = PluginSystem.uninstallPlugin(id) const success = PluginSystem.uninstallPlugin(id)
if (success) notifyPluginsChanged('uninstalled', id) if (success) notifyPluginsChanged('uninstalled', id)
@@ -127,6 +253,159 @@ ipcMain.handle('plugin:install', async () => {
return result return result
}) })
// Auth IPC Handlers
ipcMain.handle('auth:getState', () => loadAuthState())
ipcMain.handle('auth:getAccessToken', () => getValidAccessToken())
ipcMain.handle('listenTogether:getWsUrl', async (_event, params: Record<string, string>) => {
const token = await getValidAccessToken()
return getListenTogetherWsUrl({ token, ...params })
})
ipcMain.handle('auth:login', (_event, forcePrompt = false) => openLoginPage(Boolean(forcePrompt)))
ipcMain.handle('auth:qr:create', () => createQrLoginSession())
ipcMain.handle('auth:qr:poll', async (_event, sessionId: string, pollToken: string) => {
const result = await pollQrLoginSession(String(sessionId), String(pollToken))
if (result.status === 'success' && result.state) {
win?.webContents.send('auth:changed', { status: 'success', state: result.state })
}
return result
})
ipcMain.handle('auth:qr:cancel', (_event, sessionId: string, pollToken: string) => {
return cancelQrLoginSession(String(sessionId), String(pollToken))
})
ipcMain.handle('auth:refresh', () => refreshAuthState())
ipcMain.handle('auth:logout', () => {
const state = clearAuthState()
win?.webContents.send('auth:changed', { status: 'logout', state })
return state
})
ipcMain.handle('privacy:getLibrary', () => getLibraryPrivacy())
ipcMain.handle('privacy:setLibrary', (_event, payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => {
return setLibraryPrivacy({
allow_public_library: typeof payload?.allow_public_library === 'boolean' ? payload.allow_public_library : undefined,
allow_public_profile: typeof payload?.allow_public_profile === 'boolean' ? payload.allow_public_profile : undefined,
})
})
ipcMain.handle('user:getProfile', (_event, userId: string) => {
return getUserProfile(String(userId || ''))
})
ipcMain.handle('user:getPlaylists', (_event, userId: string) => {
return getUserPublicPlaylists(String(userId || ''))
})
ipcMain.handle('user:getFavSongs', (_event, userId: string) => {
return getUserPublicFavSongs(String(userId || ''))
})
ipcMain.handle('user:updateProfile', (_event, payload: any) => {
return updateCurrentUserProfile(payload || {})
})
ipcMain.handle('heartbeat:pc', (_event, duration: number, timestamp?: number) => {
return sendPcHeartbeat(duration, timestamp)
})
ipcMain.handle('stats:listenTime', (_event, detail = 1, userId?: string) => {
return getListenTime(Number(detail) || 0, userId)
})
ipcMain.handle('stats:listenRange', (_event, start: string, end: string, userId?: string) => {
return getListenTimeRange(String(start), String(end), userId)
})
ipcMain.handle('stats:listenRank', (_event, period: 'week' | 'month' | 'year' = 'week', limit = 200) => {
return getListenRank(period, Number(limit) || 200)
})
// Playlist IPC Handlers
ipcMain.handle('playlist:list', async () => {
const local = listLocalPlaylists()
const cloud = await listCloudPlaylists().catch((err) => {
console.error('[Playlist] Failed to load cloud playlists:', err)
return []
})
return { local, cloud, items: [...local, ...cloud] }
})
ipcMain.handle('playlist:publicList', (_event, search = '', sort = 'visit', page = 1, limit = 50) => {
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:create', (_event, scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {
return createPlaylist(scope, data)
})
ipcMain.handle('playlist:update', (_event, scope: PlaylistScope, id: string, info: any) => {
return updatePlaylist(scope, id, info)
})
ipcMain.handle('playlist:delete', (_event, scope: PlaylistScope, id: string) => {
return deleteStoredPlaylist(scope, id)
})
ipcMain.handle('playlist:addSong', (_event, scope: PlaylistScope, id: string, song: any, index = -1) => {
return addSong(scope, id, song, index)
})
ipcMain.handle('playlist:removeSong', (_event, scope: PlaylistScope, id: string, index: number) => {
return removeSong(scope, id, index)
})
ipcMain.handle('playlist:export', async (_event, scope: PlaylistScope, id: string) => {
if (!win) return { success: false, canceled: true }
const playlist = await getPlaylist(scope, id)
const safeName = (playlist.info.name || 'playlist').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
const { canceled, filePath } = await dialog.showSaveDialog(win, {
title: '导出歌单',
defaultPath: `${safeName}.qzplaylist.json`,
filters: [
{ name: 'QZMusic Playlist', extensions: ['json'] },
{ name: 'JSON', extensions: ['json'] },
],
})
if (canceled || !filePath) return { success: false, canceled: true }
return exportPlaylist(scope, id, filePath)
})
ipcMain.handle('playlist:import', async () => {
if (!win) return { success: false, canceled: true }
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
title: '导入歌单',
properties: ['openFile'],
filters: [
{ name: 'QZMusic Playlist', extensions: ['json'] },
{ name: 'JSON', extensions: ['json'] },
],
})
if (canceled || filePaths.length === 0) return { success: false, canceled: true }
const playlist = importPlaylist(filePaths[0])
return { success: true, playlist }
})
ipcMain.handle('playlist:convertScope', (_event, scope: PlaylistScope, id: string, targetScope: PlaylistScope) => {
return convertPlaylistScope(scope, id, targetScope)
})
ipcMain.handle('playlist:copyToLocal', (_event, scope: PlaylistScope, id: string) => {
return copyPlaylistToLocal(scope, id)
})
ipcMain.handle('image:selectAndUpload', async () => {
if (!win) return { success: false, canceled: true }
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
title: '选择图片',
properties: ['openFile'],
filters: [
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif'] },
],
})
if (canceled || filePaths.length === 0) return { success: false, canceled: true }
return uploadImage(filePaths[0])
})
// Cache IPC Handlers // Cache IPC Handlers
ipcMain.handle('cache:getInfo', () => { ipcMain.handle('cache:getInfo', () => {
const settings = loadSettings(); const settings = loadSettings();
@@ -165,6 +444,36 @@ ipcMain.handle('dialog:openDirectory', async () => {
return filePaths[0] return filePaths[0]
}) })
ipcMain.handle('dialog:openDirectories', async () => {
if (!win) return []
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
title: '选择音乐文件夹',
properties: ['openDirectory', 'multiSelections', 'createDirectory']
})
if (canceled || filePaths.length === 0) return []
return filePaths
})
ipcMain.handle('localMusic:getLibrary', () => {
return getLocalMusicLibrary()
})
ipcMain.handle('localMusic:scan', async (_event, roots: string[]) => {
return scanLocalMusic(Array.isArray(roots) ? roots : [])
})
ipcMain.handle('localMusic:setRoots', (_event, roots: string[]) => {
return setLocalMusicRoots(Array.isArray(roots) ? roots : [])
})
ipcMain.handle('localMusic:remove', (_event, id: string) => {
return removeLocalSong(id)
})
ipcMain.handle('localMusic:clearMissing', () => {
return clearMissingLocalSongs()
})
ipcMain.handle('cache:changeLocation', async (_, newPath: string) => { ipcMain.handle('cache:changeLocation', async (_, newPath: string) => {
try { try {
const settings = loadSettings() const settings = loadSettings()
@@ -299,6 +608,9 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(null) Menu.setApplicationMenu(null)
createWindow() createWindow()
const callbackUrl = process.argv.find((arg) => arg.startsWith('qzmusic://auth_result'))
if (callbackUrl) handleAuthCallback(callbackUrl)
// Start Proxy Server // Start Proxy Server
startProxyServer() startProxyServer()

235
src/main/localMusicStore.ts Normal file
View File

@@ -0,0 +1,235 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { pathToFileURL } from 'node:url'
const execFileAsync = promisify(execFile)
const AUDIO_EXTENSIONS = new Set([
'.mp3',
'.flac',
'.m4a',
'.mp4',
'.aac',
'.ogg',
'.opus',
'.wav',
'.aiff',
'.aif',
'.ape',
'.wv',
])
export interface LocalSong {
id: string
path: string
name: string
artist: string
albumName: string
duration: string
durationSeconds: number
source: 'local'
type: 'Local'
url: string
picUrl: string
lyric: string
quality: string
bitrate: number
sampleRate: number
channels: number
size: number
modifiedAt: number
addedAt: number
}
interface LocalMusicLibrary {
roots: string[]
songs: LocalSong[]
updatedAt: number
}
function getLibraryPath(): string {
return path.join(app.getPath('userData'), 'local-music.json')
}
function getReaderExe(): string {
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'),
]
const target = candidates.find((candidate) => candidate && fs.existsSync(candidate))
if (!target) throw new Error('TagLib reader executable not found')
return target
}
function getArtworkDir(): string {
return path.join(app.getPath('userData'), 'local-music-artwork')
}
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)
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
const candidate = path.join(userRoot, dirName)
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
}
}
return Array.from(new Set(roots))
}
function loadLibrary(): LocalMusicLibrary {
const file = getLibraryPath()
try {
if (fs.existsSync(file)) {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as LocalMusicLibrary
let changed = false
const songs = Array.isArray(parsed.songs)
? parsed.songs
.filter((song) => {
const exists = fs.existsSync(song.path)
if (!exists) changed = true
return exists
})
.map((song) => {
if (!song.picUrl || !song.picUrl.startsWith('data:')) return song
changed = true
return { ...song, picUrl: '' }
})
: []
const library = {
roots: Array.isArray(parsed.roots) ? parsed.roots : [],
songs,
updatedAt: Number(parsed.updatedAt) || 0,
}
if (changed || songs.length !== parsed.songs?.length) saveLibrary(library)
return library
}
} catch (error) {
console.error('[LocalMusic] Failed to load library:', error)
}
return { roots: getDefaultRoots(), songs: [], updatedAt: 0 }
}
function saveLibrary(library: LocalMusicLibrary): LocalMusicLibrary {
fs.writeFileSync(getLibraryPath(), JSON.stringify(library, null, 2), 'utf8')
return library
}
function isPathUnderRoots(filePath: string, roots: string[]): boolean {
const normalizedFile = path.resolve(filePath).toLowerCase()
return roots.some((root) => {
const normalizedRoot = path.resolve(root).toLowerCase()
return normalizedFile === normalizedRoot || normalizedFile.startsWith(`${normalizedRoot}${path.sep}`)
})
}
function fallbackName(filePath: string): string {
return path.basename(filePath, path.extname(filePath))
}
function createLocalSong(filePath: string, metadata: any): LocalSong {
const stat = fs.statSync(filePath)
const title = String(metadata.title || '').trim()
const artist = String(metadata.artist || '').trim()
const album = String(metadata.album || '').trim()
return {
id: Buffer.from(filePath, 'utf8').toString('base64url'),
path: filePath,
name: title || fallbackName(filePath),
artist: artist || '未知艺术家',
albumName: album || '未知专辑',
duration: String(metadata.duration || '00:00'),
durationSeconds: Number(metadata.durationSeconds) || 0,
source: 'local',
type: 'Local',
url: filePath,
picUrl: metadata.coverPath ? pathToFileURL(String(metadata.coverPath)).toString() : '',
lyric: String(metadata.lyric || '').trim(),
quality: String(metadata.quality || ''),
bitrate: Number(metadata.bitrate) || 0,
sampleRate: Number(metadata.sampleRate) || 0,
channels: Number(metadata.channels) || 0,
size: stat.size,
modifiedAt: stat.mtimeMs,
addedAt: Date.now(),
}
}
export function getLocalMusicLibrary(): LocalMusicLibrary {
return loadLibrary()
}
export function setLocalMusicRoots(roots: string[]): LocalMusicLibrary {
const library = loadLibrary()
const normalizedRoots = Array.from(new Set(
roots
.map((root) => path.resolve(root))
.filter((root) => fs.existsSync(root) && fs.statSync(root).isDirectory())
))
return saveLibrary({
...library,
roots: normalizedRoots,
songs: library.songs.filter((song) => fs.existsSync(song.path) && isPathUnderRoots(song.path, normalizedRoots)),
updatedAt: Date.now(),
})
}
export async function scanLocalMusic(roots: string[]): Promise<LocalMusicLibrary> {
const normalizedRoots = Array.from(new Set(
roots
.map((root) => path.resolve(root))
.filter((root) => fs.existsSync(root) && fs.statSync(root).isDirectory())
))
const reader = getReaderExe()
const artworkDir = getArtworkDir()
fs.mkdirSync(artworkDir, { recursive: true })
const { stdout, stderr } = await execFileAsync(reader, ['scan', '--artwork-dir', artworkDir, ...normalizedRoots], {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
windowsHide: true,
timeout: 15 * 60 * 1000,
})
if (stderr?.trim()) console.warn('[LocalMusic] Scanner warnings:', stderr)
const parsed = JSON.parse(stdout) as { songs?: any[] }
const songs: LocalSong[] = []
for (const metadata of parsed.songs || []) {
const filePath = String(metadata?.path || '')
if (!filePath || !fs.existsSync(filePath) || !AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) continue
try {
songs.push(createLocalSong(filePath, metadata))
} catch (error) {
console.warn('[LocalMusic] Failed to normalize tags:', filePath, error)
}
}
return saveLibrary({
roots: normalizedRoots,
songs,
updatedAt: Date.now(),
})
}
export function removeLocalSong(id: string): LocalMusicLibrary {
const library = loadLibrary()
return saveLibrary({
...library,
songs: library.songs.filter((song) => song.id !== id),
updatedAt: Date.now(),
})
}
export function clearMissingLocalSongs(): LocalMusicLibrary {
const library = loadLibrary()
return saveLibrary({
...library,
songs: library.songs.filter((song) => fs.existsSync(song.path)),
updatedAt: Date.now(),
})
}

412
src/main/playlistStore.ts Normal file
View File

@@ -0,0 +1,412 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { loadAuthState, qzFetch } from './authStore'
export type PlaylistScope = 'local' | 'cloud'
export interface PlaylistSong {
id: string
name: string
artist?: string
artists?: string
source: string
picUrl?: string
pic?: string
mPic?: string
sPic?: string
albumName?: string | null
albumId?: string | null
duration?: string
interval?: string
url?: string
type?: string
qualities?: Record<string, string>
types?: Record<string, string>
}
export interface PlaylistInfo {
id: string
name: string
desc: string
img: string
cover_mode?: 'auto' | 'custom' | string
author?: string
play_count?: string
visit_count?: number
is_public?: boolean
}
export interface AppPlaylist {
id: string
scope: PlaylistScope
source: PlaylistScope
info: PlaylistInfo
list: PlaylistSong[]
total: number
}
type LocalPlaylistFile = Omit<AppPlaylist, 'scope' | 'source' | 'total'> & {
id: string
list: PlaylistSong[]
info: PlaylistInfo
}
function getPlaylistsDir(): string {
return path.join(app.getPath('userData'), 'playlists')
}
function getLocalPlaylistPath(id: string): string {
return path.join(getPlaylistsDir(), `${id}.json`)
}
function ensurePlaylistsDir(): void {
fs.mkdirSync(getPlaylistsDir(), { recursive: true })
}
function assertLocalId(id: string): void {
if (!/^[a-z0-9._-]+$/i.test(id)) throw new Error('Invalid playlist id')
}
function normalizeSong(song: any): PlaylistSong {
const id = String(song?.id ?? song?.songmid ?? song?.songId ?? '')
const artist = song?.artist ?? song?.artists ?? song?.singer ?? ''
const pic = song?.picUrl ?? song?.pic ?? song?.mPic ?? song?.img ?? ''
const types = song?.types ?? song?.qualities ?? {}
return {
...song,
id,
name: String(song?.name ?? ''),
artist: Array.isArray(artist) ? artist.join('、') : String(artist),
artists: Array.isArray(artist) ? artist.join('、') : String(artist),
source: String(song?.source ?? 'local'),
picUrl: pic,
pic,
mPic: song?.mPic ?? song?.m_img ?? pic,
sPic: song?.sPic ?? song?.s_img ?? pic,
interval: String(song?.interval ?? song?.duration ?? ''),
duration: String(song?.duration ?? song?.interval ?? ''),
type: song?.type ?? (song?.source === 'local' ? 'Local' : 'Remote'),
qualities: types,
types,
}
}
function normalizeLocalPlaylist(raw: LocalPlaylistFile): AppPlaylist {
const list = Array.isArray(raw.list) ? raw.list.map(normalizeSong) : []
return {
id: raw.id,
scope: 'local',
source: 'local',
info: {
id: raw.id,
name: raw.info?.name || '新建歌单',
desc: raw.info?.desc || '',
img: raw.info?.img || list[0]?.picUrl || '',
cover_mode: raw.info?.cover_mode || 'auto',
author: raw.info?.author || '本地',
play_count: raw.info?.play_count || '',
visit_count: Number(raw.info?.visit_count ?? raw.info?.play_count ?? 0) || 0,
is_public: Boolean(raw.info?.is_public),
},
list,
total: list.length,
}
}
function normalizeCloudPlaylist(raw: any): AppPlaylist {
const info = raw?.info ?? raw
const list = Array.isArray(raw?.list) ? raw.list.map(normalizeSong) : []
const id = String(info?.id ?? raw?.id ?? '')
const total = Number(raw?.total ?? info?.total ?? list.length) || list.length
return {
id,
scope: 'cloud',
source: 'cloud',
info: {
id,
name: info?.name || '云端歌单',
desc: info?.desc || '',
img: info?.img || info?.pic || list[0]?.picUrl || '',
cover_mode: info?.cover_mode || info?.coverMode || 'auto',
author: info?.author || '',
play_count: info?.play_count || '',
visit_count: Number(info?.visit_count ?? info?.play_count ?? 0) || 0,
is_public: Boolean(info?.is_public ?? info?.public ?? false),
},
list,
total,
}
}
function readLocalPlaylist(id: string): AppPlaylist {
assertLocalId(id)
const raw = JSON.parse(fs.readFileSync(getLocalPlaylistPath(id), 'utf8')) as LocalPlaylistFile
return normalizeLocalPlaylist(raw)
}
function writeLocalPlaylist(playlist: AppPlaylist): AppPlaylist {
ensurePlaylistsDir()
assertLocalId(playlist.id)
const normalized = normalizeLocalPlaylist({
id: playlist.id,
info: playlist.info,
list: playlist.list,
})
fs.writeFileSync(getLocalPlaylistPath(playlist.id), JSON.stringify({
id: normalized.id,
info: normalized.info,
list: normalized.list,
}, null, 2), 'utf8')
return normalized
}
function createLocalPlaylistCopy(playlist: AppPlaylist): AppPlaylist {
const id = crypto.randomUUID()
return writeLocalPlaylist({
...playlist,
id,
scope: 'local',
source: 'local',
info: {
...playlist.info,
id,
author: '本地',
},
list: playlist.list.map(normalizeSong),
total: playlist.list.length,
})
}
async function createCloudPlaylistCopy(playlist: AppPlaylist): Promise<AppPlaylist> {
const list = playlist.list.map(normalizeSong)
const info = {
...playlist.info,
id: '',
author: '',
is_public: Boolean(playlist.info.is_public),
}
const result = await qzFetch('/playlist/', {
method: 'POST',
body: JSON.stringify({ info, list }),
})
if (result?.status !== 'success' || !result.id) {
throw new Error(result?.message || 'Create cloud playlist failed')
}
const id = String(result.id)
let created = await getPlaylist('cloud', id)
if ((created.list?.length || 0) === 0 && list.length > 0) {
for (const song of list) {
const addResult = await qzFetch(`/playlist/${encodeURIComponent(id)}/list`, {
method: 'POST',
body: JSON.stringify({ data: song, index: -1 }),
})
if (addResult?.status !== 'success') throw new Error(addResult?.message || 'Add song failed')
}
created = await getPlaylist('cloud', id)
}
return created
}
function normalizeImportedPlaylist(raw: any): AppPlaylist {
const payload = raw?.type === 'qzmusic-playlist' ? raw : raw?.playlist ? raw.playlist : raw
const rawInfo = payload?.info ?? payload
const list = Array.isArray(payload?.list) ? payload.list.map(normalizeSong) : []
const id = crypto.randomUUID()
return normalizeLocalPlaylist({
id,
info: {
id,
name: rawInfo?.name || '导入的歌单',
desc: rawInfo?.desc || '',
img: rawInfo?.img || rawInfo?.pic || list[0]?.picUrl || '',
cover_mode: rawInfo?.cover_mode || rawInfo?.coverMode || 'auto',
author: '本地',
play_count: '',
visit_count: 0,
},
list,
})
}
export function listLocalPlaylists(): AppPlaylist[] {
ensurePlaylistsDir()
return fs.readdirSync(getPlaylistsDir(), { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => {
try {
return readLocalPlaylist(path.basename(entry.name, '.json'))
} catch (err) {
console.error('[Playlist] Failed to read local playlist:', entry.name, err)
return null
}
})
.filter((playlist): playlist is AppPlaylist => playlist !== null)
}
export async function listCloudPlaylists(): Promise<AppPlaylist[]> {
const userId = loadAuthState().userInfo?.id
if (!userId) return []
const raw = await qzFetch(`/user/${encodeURIComponent(userId)}/playlists`)
return Array.isArray(raw) ? raw.map(normalizeCloudPlaylist) : []
}
export async function listPublicPlaylists(
search = '',
sort = 'visit',
page = 1,
limit = 50,
): Promise<{ items: AppPlaylist[]; total: number; page: number; limit: number; sort: string }> {
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'].includes(sort) ? sort : 'visit',
})
if (search.trim()) query.set('search', search.trim())
const raw = await qzFetch(`/playlist/public?${query.toString()}`)
const items = Array.isArray(raw?.items) ? raw.items.map(normalizeCloudPlaylist) : []
return {
items,
total: Number(raw?.total ?? items.length) || items.length,
page: Number(raw?.page ?? page) || page,
limit: Number(raw?.limit ?? limit) || limit,
sort: String(raw?.sort ?? sort),
}
}
export async function getPlaylist(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
if (scope === 'local') return readLocalPlaylist(id)
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}`)
return normalizeCloudPlaylist(raw)
}
export async function createPlaylist(scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }): Promise<AppPlaylist> {
const id = scope === 'local' ? crypto.randomUUID() : ''
const playlist: AppPlaylist = {
id,
scope,
source: scope,
info: {
id,
name: data.name.trim() || '新建歌单',
desc: data.desc?.trim() || '',
img: '',
author: scope === 'local' ? '本地' : '',
play_count: '',
visit_count: 0,
is_public: scope === 'cloud' ? Boolean(data.is_public) : false,
},
list: [],
total: 0,
}
if (scope === 'local') return writeLocalPlaylist(playlist)
const result = await qzFetch('/playlist/', {
method: 'POST',
body: JSON.stringify({ info: playlist.info, list: [] }),
})
if (result?.status !== 'success' || !result.id) {
throw new Error(result?.message || 'Create cloud playlist failed')
}
return getPlaylist('cloud', String(result.id))
}
export async function updatePlaylist(scope: PlaylistScope, id: string, info: Partial<PlaylistInfo>): Promise<AppPlaylist> {
if (scope === 'local') {
const playlist = readLocalPlaylist(id)
playlist.info = { ...playlist.info, ...info, id }
return writeLocalPlaylist(playlist)
}
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(info),
})
if (result?.status !== 'success') throw new Error(result?.message || 'Update cloud playlist failed')
return getPlaylist('cloud', id)
}
export async function copyPlaylistToLocal(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
const playlist = await getPlaylist(scope, id)
return createLocalPlaylistCopy(playlist)
}
export async function deletePlaylist(scope: PlaylistScope, id: string): Promise<{ success: boolean }> {
if (scope === 'local') {
assertLocalId(id)
const target = getLocalPlaylistPath(id)
if (fs.existsSync(target)) fs.unlinkSync(target)
return { success: true }
}
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { success: result?.status === 'success' }
}
export async function addSong(scope: PlaylistScope, id: string, song: PlaylistSong, index = -1): Promise<AppPlaylist> {
const normalizedSong = normalizeSong(song)
if (scope === 'local') {
const playlist = readLocalPlaylist(id)
const exists = playlist.list.some((item) => item.id === normalizedSong.id && item.source === normalizedSong.source)
if (!exists) {
if (index >= 0 && index <= playlist.list.length) playlist.list.splice(index, 0, normalizedSong)
else playlist.list.push(normalizedSong)
}
playlist.info.img = playlist.info.img || normalizedSong.picUrl || ''
return writeLocalPlaylist(playlist)
}
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}/list`, {
method: 'POST',
body: JSON.stringify({ data: normalizedSong, index }),
})
if (result?.status !== 'success') throw new Error(result?.message || 'Add song failed')
return getPlaylist('cloud', id)
}
export async function removeSong(scope: PlaylistScope, id: string, index: number): Promise<AppPlaylist> {
if (scope === 'local') {
const playlist = readLocalPlaylist(id)
if (index >= 0 && index < playlist.list.length) playlist.list.splice(index, 1)
playlist.info.img = playlist.list[0]?.picUrl || ''
return writeLocalPlaylist(playlist)
}
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}/list/${index}`, { method: 'DELETE' })
if (result?.status !== 'success') throw new Error(result?.message || 'Remove song failed')
return getPlaylist('cloud', id)
}
export async function exportPlaylist(scope: PlaylistScope, id: string, filePath: string): Promise<{ success: boolean; path: string }> {
const playlist = await getPlaylist(scope, id)
const payload = {
type: 'qzmusic-playlist',
version: 1,
exportedAt: new Date().toISOString(),
info: playlist.info,
list: playlist.list.map(normalizeSong),
}
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8')
return { success: true, path: filePath }
}
export function importPlaylist(filePath: string): AppPlaylist {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
return writeLocalPlaylist(normalizeImportedPlaylist(raw))
}
export async function convertPlaylistScope(scope: PlaylistScope, id: string, targetScope: PlaylistScope): Promise<AppPlaylist> {
if (scope === targetScope) return getPlaylist(scope, id)
const playlist = await getPlaylist(scope, id)
const converted = targetScope === 'local'
? createLocalPlaylistCopy(playlist)
: await createCloudPlaylistCopy(playlist)
const deleted = await deletePlaylist(scope, id)
if (!deleted.success) throw new Error('Converted playlist, but failed to delete source playlist')
return converted
}

View File

@@ -30,10 +30,15 @@ type PluginModule = Record<string, any> & {
info?: PluginInfo['info'] info?: PluginInfo['info']
getUrl?: (...args: any[]) => any getUrl?: (...args: any[]) => any
getLyric?: (...args: any[]) => any getLyric?: (...args: any[]) => any
getPlaylist?: (...args: any[]) => any
getPlayList?: (...args: any[]) => any
getAlbum?: (...args: any[]) => any
musicSearch?: { musicSearch?: {
search?: (...args: any[]) => any search?: (...args: any[]) => any
} | ((...args: any[]) => any) } | ((...args: any[]) => any)
search?: (...args: any[]) => any search?: (...args: any[]) => any
songList?: Record<string, any>
album?: Record<string, any>
} }
type ModuleCacheEntry = { type ModuleCacheEntry = {
@@ -171,6 +176,80 @@ function normalizeSearchResult(result: any, pluginId: string): any {
} }
} }
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
function normalizeDuration(value: any): string {
if (typeof value === 'string' && /^\d{1,3}:\d{2}$/.test(value)) return value
const milliseconds = Number(value)
return Number.isFinite(milliseconds) ? formatDuration(milliseconds) : '00:00'
}
function normalizeSongForApp(item: any, pluginId: string): any {
const normalized = normalizeSearchItem(item, pluginId)
const picUrl = normalized.m_img || normalized.mPic || normalized.pic || normalized.img || normalized.picUrl || ''
return {
...normalized,
id: String(normalized.id || normalized.songmid || ''),
name: String(normalized.name || ''),
artist: String(normalized.artists || normalized.singer || normalized.artist || ''),
picUrl,
url: '',
duration: normalizeDuration(normalized.interval ?? normalized.duration ?? normalized.dt),
source: String(normalized.source || pluginId),
albumId: normalized.albumId ? String(normalized.albumId) : null,
albumName: normalized.albumName || '',
type: 'Remote',
quality: 'auto',
types: normalized.types ?? normalized.qualities ?? {},
}
}
function normalizePluginCollection(result: any, pluginId: string, id: string, kind: 'playlist' | 'album'): any {
const rawList = Array.isArray(result) ? result : result?.list
const list = Array.isArray(rawList)
? rawList.filter(Boolean).map((item) => normalizeSongForApp(item, pluginId))
: []
const info = result?.info ?? result ?? {}
const title = info.name || info.title || (kind === 'album' ? '插件专辑' : '插件歌单')
const desc = info.desc || info.description || ''
const img = info.img || info.pic || info.picUrl || info.cover || list[0]?.picUrl || ''
const author = info.author || info.artist || info.creator || ''
return {
id: String(info.id ?? id),
scope: 'plugin',
source: pluginId,
kind,
info: {
id: String(info.id ?? id),
name: String(title),
desc: String(desc),
img: String(img),
author: String(author),
play_count: String(info.play_count || info.playCount || ''),
},
list,
total: Number(result?.total ?? info.total ?? list.length) || list.length,
page: Number(result?.page ?? 1) || 1,
limit: Number(result?.limit ?? list.length) || list.length,
}
}
async function callCandidate(candidates: Array<{ target: any; method: string; args: any[] }>): Promise<any> {
for (const candidate of candidates) {
const fn = candidate.target?.[candidate.method]
if (typeof fn !== 'function') continue
return await unwrapPluginResult(fn.apply(candidate.target, candidate.args))
}
throw new Error('Method not found')
}
export class PluginSystem { export class PluginSystem {
private readonly pluginId: string private readonly pluginId: string
private plugin: PluginModule | null = null private plugin: PluginModule | null = null
@@ -210,6 +289,12 @@ export class PluginSystem {
if (method === 'getUrl') { if (method === 'getUrl') {
return this.getUrl(String(args[0] ?? ''), String(args[1] ?? '')) return this.getUrl(String(args[0] ?? ''), String(args[1] ?? ''))
} }
if (method === 'getPlaylist') {
return this.getPlaylist(String(args[0] ?? ''), Number(args[1]) || 1, Number(args[2]) || 100)
}
if (method === 'getAlbum') {
return this.getAlbum(String(args[0] ?? ''), Number(args[1]) || 1, Number(args[2]) || 100)
}
const plugin = this.getRequiredPlugin() const plugin = this.getRequiredPlugin()
const target = plugin[method] const target = plugin[method]
@@ -290,6 +375,29 @@ export class PluginSystem {
} }
} }
async getPlaylist(id: string, page = 1, limit = 100): Promise<any> {
const plugin = this.getRequiredPlugin()
const result = await callCandidate([
{ target: plugin, method: 'getPlaylist', args: [id, page, limit] },
{ target: plugin, method: 'getPlayList', args: [id, page, limit] },
{ target: plugin, method: 'getMusicSheet', args: [id, page, limit] },
{ target: plugin.songList, method: 'getListDetail', args: [id, page, limit] },
{ target: plugin.songList, method: 'getPlaylistDetail', args: [id, page, limit] },
])
return normalizePluginCollection(result, this.pluginId, id, 'playlist')
}
async getAlbum(id: string, page = 1, limit = 100): Promise<any> {
const plugin = this.getRequiredPlugin()
const result = await callCandidate([
{ target: plugin, method: 'getAlbum', args: [id, page, limit] },
{ target: plugin, method: 'getMusicAlbum', args: [id, page, limit] },
{ target: plugin.album, method: 'getListDetail', args: [id, page, limit] },
{ target: plugin.songList, method: 'getAlbumDetail', args: [id, page, limit] },
])
return normalizePluginCollection(result, this.pluginId, id, 'album')
}
static getAllPlugins(): any[] { static getAllPlugins(): any[] {
const pluginsPath = getPluginsPath() const pluginsPath = getPluginsPath()
if (!fs.existsSync(pluginsPath)) return [] if (!fs.existsSync(pluginsPath)) return []

View File

@@ -9,13 +9,18 @@ export interface AppSettings {
// Appearance // Appearance
theme: 'dark' | 'light'; theme: 'dark' | 'light';
accentColor: string; accentColor: string;
// Playlist
playlistPagingMode: 'infinite' | 'pagination';
openPlayerOnSongClick: boolean;
} }
const DEFAULT_SETTINGS: AppSettings = { const DEFAULT_SETTINGS: AppSettings = {
persistCache: true, persistCache: true,
cachePath: path.join(app.getPath('userData'), 'cache'), // Default cachePath: path.join(app.getPath('userData'), 'cache'), // Default
theme: 'light', theme: 'light',
accentColor: '#ec4141', // Default red accentColor: '#8289d3',
playlistPagingMode: 'infinite',
openPlayerOnSongClick: false,
}; };
let settingsCache: AppSettings | null = null; let settingsCache: AppSettings | null = null;

View File

@@ -1,11 +1,25 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
const toPlainStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value.map((item) => String(item)).filter(Boolean)
}
const toCloneableObject = (value: unknown): any => {
try {
return JSON.parse(JSON.stringify(value ?? {}))
} catch {
return {}
}
}
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// 窗口控制 // 窗口控制
minimizeWindow: () => ipcRenderer.send('window-minimize'), minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'), maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'), closeWindow: () => ipcRenderer.send('window-close'),
isMaximized: () => ipcRenderer.invoke('window-is-maximized'), isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
setTaskbarProgress: (progress: number, mode: 'normal' | 'paused' = 'normal') => ipcRenderer.invoke('window:setProgressBar', progress, mode),
// qzplayer Control // qzplayer Control
qzplayer: { qzplayer: {
@@ -24,6 +38,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args), call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]), search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]), getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]),
getPlaylist: (pluginId: string, id: string, page = 1, limit = 100) => ipcRenderer.invoke('plugin:getPlaylist', pluginId, id, page, limit),
getAlbum: (pluginId: string, id: string, page = 1, limit = 100) => ipcRenderer.invoke('plugin:getAlbum', pluginId, id, page, limit),
getAll: () => ipcRenderer.invoke('plugin:getAll'), getAll: () => ipcRenderer.invoke('plugin:getAll'),
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id), uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
install: () => ipcRenderer.invoke('plugin:install'), install: () => ipcRenderer.invoke('plugin:install'),
@@ -37,6 +53,67 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
}, },
auth: {
getState: () => ipcRenderer.invoke('auth:getState'),
getAccessToken: () => ipcRenderer.invoke('auth:getAccessToken'),
login: (forcePrompt = false) => ipcRenderer.invoke('auth:login', forcePrompt),
qrCreate: () => ipcRenderer.invoke('auth:qr:create'),
qrPoll: (sessionId: string, pollToken: string) => ipcRenderer.invoke('auth:qr:poll', sessionId, pollToken),
qrCancel: (sessionId: string, pollToken: string) => ipcRenderer.invoke('auth:qr:cancel', sessionId, pollToken),
refresh: () => ipcRenderer.invoke('auth:refresh'),
logout: () => ipcRenderer.invoke('auth:logout'),
onChanged: (callback: (payload: any) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: any) => callback(payload)
ipcRenderer.on('auth:changed', listener)
return () => ipcRenderer.removeListener('auth:changed', listener)
},
},
listenTogether: {
getWsUrl: (params: Record<string, string>) => ipcRenderer.invoke('listenTogether:getWsUrl', params),
},
heartbeat: {
sendPc: (duration: number, timestamp?: number) => ipcRenderer.invoke('heartbeat:pc', duration, timestamp),
},
stats: {
getListenTime: (detail = 1, userId?: string) => ipcRenderer.invoke('stats:listenTime', detail, userId),
getListenRange: (start: string, end: string, userId?: string) => ipcRenderer.invoke('stats:listenRange', start, end, userId),
getListenRank: (period: 'week' | 'month' | 'year' = 'week', limit = 200) => ipcRenderer.invoke('stats:listenRank', period, limit),
},
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),
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),
addSong: (scope: 'local' | 'cloud', id: string, song: any, index = -1) => ipcRenderer.invoke('playlist:addSong', scope, id, toCloneableObject(song), index),
removeSong: (scope: 'local' | 'cloud', id: string, index: number) => ipcRenderer.invoke('playlist:removeSong', scope, id, index),
export: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:export', scope, id),
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),
},
image: {
selectAndUpload: () => ipcRenderer.invoke('image:selectAndUpload'),
},
privacy: {
getLibrary: () => ipcRenderer.invoke('privacy:getLibrary'),
setLibrary: (payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => ipcRenderer.invoke('privacy:setLibrary', toCloneableObject(payload)),
},
user: {
getProfile: (userId: string) => ipcRenderer.invoke('user:getProfile', userId),
getPlaylists: (userId: string) => ipcRenderer.invoke('user:getPlaylists', userId),
getFavSongs: (userId: string) => ipcRenderer.invoke('user:getFavSongs', userId),
updateProfile: (payload: any) => ipcRenderer.invoke('user:updateProfile', toCloneableObject(payload)),
},
// Cache Control // Cache Control
getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'), getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'),
setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist), setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist),
@@ -44,6 +121,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearCache: () => ipcRenderer.invoke('cache:clear'), clearCache: () => ipcRenderer.invoke('cache:clear'),
changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath), changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath),
selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'), selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
selectDirectories: () => ipcRenderer.invoke('dialog:openDirectories'),
localMusic: {
getLibrary: () => ipcRenderer.invoke('localMusic:getLibrary'),
scan: (roots: string[]) => ipcRenderer.invoke('localMusic:scan', toPlainStringArray(roots)),
setRoots: (roots: string[]) => ipcRenderer.invoke('localMusic:setRoots', toPlainStringArray(roots)),
remove: (id: string) => ipcRenderer.invoke('localMusic:remove', String(id)),
clearMissing: () => ipcRenderer.invoke('localMusic:clearMissing'),
},
// Settings // Settings
settings: { settings: {

View File

@@ -1,27 +1,73 @@
<template> <template>
<MainLayout /> <MainLayout />
<FullScreenPlayer /> <FullScreenPlayer />
<LoginDialog v-model:visible="showLoginDialog" />
<Settings v-if="showSettings" @close="showSettings = false" /> <Settings v-if="showSettings" @close="showSettings = false" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, provide, onMounted } from 'vue'; import { ref, provide, onMounted, onBeforeUnmount } from 'vue';
import MainLayout from './layout/MainLayout.vue'; import MainLayout from './layout/MainLayout.vue';
import Settings from './components/Settings.vue'; import Settings from './components/Settings.vue';
import FullScreenPlayer from './components/FullScreenPlayer.vue'; import FullScreenPlayer from './components/FullScreenPlayer.vue';
import LoginDialog from './components/LoginDialog.vue';
import { useAuthStore } from './stores/auth';
import { usePlaylistsStore } from './stores/playlists';
import { usePlayerStore } from './stores/player';
const showSettings = ref(false); const showSettings = ref(false);
const showLoginDialog = ref(false);
const authStore = useAuthStore();
const playlistsStore = usePlaylistsStore();
const playerStore = usePlayerStore();
// Provide to child components // Provide to child components
provide('openSettings', () => { showSettings.value = true; }); provide('openSettings', () => { showSettings.value = true; });
provide('openLoginDialog', () => { showLoginDialog.value = true; });
const isTypingTarget = (target: EventTarget | null) => {
const element = target as HTMLElement | null;
return Boolean(
element?.closest('input, textarea, select, [contenteditable="true"]')
);
};
const handleGlobalShortcut = (event: KeyboardEvent) => {
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey || isTypingTarget(event.target)) return;
const key = event.key.toLowerCase();
if (event.code === 'Space') {
event.preventDefault();
playerStore.togglePlay();
} else if (key === 'a') {
event.preventDefault();
playerStore.prev();
} else if (key === 'd') {
event.preventDefault();
playerStore.next();
}
};
// Apply saved theme on app startup // Apply saved theme on app startup
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', handleGlobalShortcut);
if (window.electronAPI?.settings) { if (window.electronAPI?.settings) {
const settings = await window.electronAPI.settings.getAll(); const settings = await window.electronAPI.settings.getAll();
document.documentElement.setAttribute('data-theme', settings.theme); document.documentElement.setAttribute('data-theme', settings.theme);
document.documentElement.style.setProperty('--color-accent', settings.accentColor); const accentColor = settings.accentColor === '#b3c9df' ? '#8289d3' : settings.accentColor;
document.documentElement.style.setProperty('--color-accent', accentColor);
document.documentElement.style.setProperty('--color-accent-gradient', accentColor);
const atmosphere = accentColor === '#8289d3'
? 'linear-gradient(180deg, rgba(176, 186, 235, 0.36) 0%, rgba(177, 191, 233, 0.31) 18%, rgba(179, 201, 223, 0.25) 38%, rgba(193, 192, 211, 0.18) 58%, rgba(223, 172, 185, 0.11) 78%, transparent 100%)'
: 'linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, color-mix(in srgb, var(--color-accent) 7%, transparent) 44%, transparent 100%)';
document.documentElement.style.setProperty('--color-atmosphere-gradient', atmosphere);
} }
await authStore.init();
await playlistsStore.refresh();
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalShortcut);
}); });
</script> </script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

View File

@@ -16,7 +16,7 @@
<ControlThumb @click="toggleFullScreen" /> <ControlThumb @click="toggleFullScreen" />
</div> </div>
<Cover <Cover
class="cover" :class="['cover', { 'shared-cover': isPlayerFullScreen }]"
:cover-url="playerStore.currentSong?.picUrl" :cover-url="playerStore.currentSong?.picUrl"
:music-paused="!isPlaying" :music-paused="!isPlaying"
:cover-video-paused="!isPlaying" :cover-video-paused="!isPlaying"
@@ -101,31 +101,7 @@
<span class="playlist-title">播放队列</span> <span class="playlist-title">播放队列</span>
<span class="playlist-count">{{ playerStore.playlist.length }} 首</span> <span class="playlist-count">{{ playerStore.playlist.length }} 首</span>
</div> </div>
<div class="playlist-scroll"> <PlayerQueueList class="playlist-scroll" variant="fullscreen" />
<div
v-for="(song, index) in playerStore.playlist"
:key="song.id"
class="playlist-item"
:class="{ active: index === playerStore.playlist.findIndex(s => s.id === playerStore.currentSong?.id) }"
@click="playFromPlaylist(index)"
>
<div class="item-index">
<span v-if="index === playerStore.playlist.findIndex(s => s.id === playerStore.currentSong?.id)" class="playing-indicator">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span v-else>{{ index + 1 }}</span>
</div>
<img v-if="song.picUrl" :src="song.picUrl" class="item-cover" />
<div v-else class="item-cover item-cover-placeholder"></div>
<div class="item-info">
<div class="item-name">{{ song.name }}</div>
<div class="item-artist">{{ song.artist }}</div>
</div>
<div class="item-duration">{{ song.duration }}</div>
</div>
</div>
</div> </div>
</Transition> </Transition>
<!-- Lyric Player --> <!-- Lyric Player -->
@@ -181,6 +157,7 @@ import MusicInfo from './player/MusicInfo.vue';
import MediaButton from './player/MediaButton.vue'; import MediaButton from './player/MediaButton.vue';
import VolumeControl from './player/VolumeControl.vue'; import VolumeControl from './player/VolumeControl.vue';
import ToggleIconButton from './player/ToggleIconButton.vue'; import ToggleIconButton from './player/ToggleIconButton.vue';
import PlayerQueueList from './player/PlayerQueueList.vue';
// Icons // Icons
import IconRewind from '@assets/icon_rewind.svg'; import IconRewind from '@assets/icon_rewind.svg';
@@ -357,13 +334,6 @@ const togglePlaylistPanel = () => {
showPlaylistPanel.value = opening; showPlaylistPanel.value = opening;
}; };
const playFromPlaylist = (index: number) => {
const song = playerStore.playlist[index];
if (song) {
playerStore.playSong(song);
}
};
// watch(()=>playerStore.currentTime,(t)=>{ // watch(()=>playerStore.currentTime,(t)=>{
// console.log(toRaw(t)) // console.log(toRaw(t))
// }) // })
@@ -373,18 +343,26 @@ const playFromPlaylist = (index: number) => {
.fullscreen-player { .fullscreen-player {
--height: calc(100vh); --height: calc(100vh);
position: fixed; position: fixed;
top: var(--height); top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: var(--height); height: var(--height);
z-index: 9999; z-index: 9999;
transition: top 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); opacity: 0;
transform: translateY(100%) scale(0.985);
transform-origin: bottom center;
transition:
transform 0.46s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.28s ease;
background: black; /* Default background if image fails */ background: black; /* Default background if image fails */
overflow: hidden; overflow: hidden;
pointer-events: none;
} }
.fullscreen-player.active { .fullscreen-player.active {
top: 0; opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
} }
.background-container { .background-container {
@@ -509,6 +487,10 @@ const playFromPlaylist = (index: number) => {
position: relative; position: relative;
} }
.cover.shared-cover {
view-transition-name: now-playing-cover;
}
.controls { .controls {
grid-area: music-info / info-side; grid-area: music-info / info-side;
will-change: transform; will-change: transform;

View File

@@ -0,0 +1,307 @@
<template>
<Teleport to="body">
<Transition name="login-fade">
<div v-if="visible" class="login-backdrop" @click.self="close">
<section class="login-panel" role="dialog" aria-modal="true" aria-label="登录">
<header class="login-header">
<div>
<h2>登录 QZ Music</h2>
<p>{{ statusText }}</p>
</div>
<button class="icon-btn" title="关闭" @click="close">
<Icon icon="lucide:x" />
</button>
</header>
<div class="qr-stage" :class="{ muted: status === 'loading' || status === 'expired' || status === 'error' }">
<img v-if="session?.qr_data_url" :src="session.qr_data_url" alt="扫码登录二维码" />
<Icon v-else icon="lucide:qr-code" class="qr-placeholder" />
<div v-if="status === 'loading'" class="qr-overlay">
<Icon icon="lucide:loader-2" class="spin" />
</div>
</div>
<div class="login-actions">
<button
v-if="status === 'expired' || status === 'error' || status === 'cancelled'"
class="primary-btn"
@click="startQrLogin"
>
<Icon icon="lucide:refresh-cw" />
刷新二维码
</button>
<button class="secondary-btn" @click="openBrowserLogin">
<Icon icon="lucide:globe" />
浏览器登录
</button>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import type { QrLoginSession } from '../types/electron'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{ (event: 'update:visible', value: boolean): void }>()
type QrStatus = 'idle' | 'loading' | 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'error'
const session = ref<QrLoginSession | null>(null)
const status = ref<QrStatus>('idle')
const errorMessage = ref('')
let pollTimer: number | undefined
const statusText = computed(() => {
if (status.value === 'loading') return '正在生成二维码'
if (status.value === 'scanned') return '已扫码,请在手机上确认'
if (status.value === 'success') return '登录成功'
if (status.value === 'expired') return '二维码已过期'
if (status.value === 'cancelled') return '二维码已取消'
if (status.value === 'error') return errorMessage.value || '二维码加载失败'
return '使用安卓端扫码,或选择浏览器登录'
})
const clearPollTimer = () => {
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = undefined
}
}
const cancelActiveSession = async () => {
const current = session.value
if (!current || status.value === 'success') return
try {
await window.electronAPI.auth.qrCancel(current.session_id, current.poll_token)
} catch (err) {
console.warn('[Auth] cancel QR login failed:', err)
}
}
const pollQrLogin = async () => {
const current = session.value
if (!current) return
try {
const result = await window.electronAPI.auth.qrPoll(current.session_id, current.poll_token)
if (result.status === 'success') {
status.value = 'success'
clearPollTimer()
ElMessage.success('登录成功')
window.setTimeout(() => emit('update:visible', false), 500)
return
}
if (result.status === 'scanned') {
status.value = 'scanned'
return
}
if (result.status === 'expired' || result.status === 'cancelled') {
status.value = result.status
clearPollTimer()
return
}
if (result.status === 'error') {
status.value = 'error'
errorMessage.value = result.message || '二维码登录失败'
clearPollTimer()
}
} catch (err: any) {
status.value = 'error'
errorMessage.value = err?.message || '二维码登录失败'
clearPollTimer()
}
}
const startPolling = () => {
clearPollTimer()
pollTimer = window.setInterval(pollQrLogin, 1500)
}
const startQrLogin = async () => {
clearPollTimer()
session.value = null
errorMessage.value = ''
status.value = 'loading'
try {
session.value = await window.electronAPI.auth.qrCreate()
status.value = 'pending'
startPolling()
} catch (err: any) {
status.value = 'error'
errorMessage.value = err?.message || '二维码加载失败'
}
}
const openBrowserLogin = async () => {
clearPollTimer()
await cancelActiveSession()
await window.electronAPI.auth.login(false)
emit('update:visible', false)
}
const close = async () => {
clearPollTimer()
await cancelActiveSession()
emit('update:visible', false)
}
watch(
() => props.visible,
(visible) => {
if (visible) startQrLogin()
else clearPollTimer()
},
)
onBeforeUnmount(() => {
clearPollTimer()
})
</script>
<style scoped>
.login-backdrop {
position: fixed;
inset: 0;
z-index: 5000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.52);
}
.login-panel {
width: min(360px, calc(100vw - 32px));
padding: 22px;
border-radius: 20px;
background: var(--color-bg-primary);
box-shadow: var(--shadow-elevated);
}
.login-header {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.login-header h2 {
margin: 0;
font-size: 20px;
color: var(--color-text-primary);
}
.login-header p {
margin: 6px 0 0;
font-size: 13px;
color: var(--color-text-muted);
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--color-text-secondary);
}
.icon-btn:hover {
color: var(--color-text-primary);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.qr-stage {
position: relative;
width: 256px;
height: 256px;
margin: 0 auto 18px;
border-radius: 18px;
display: grid;
place-items: center;
background: #fff;
overflow: hidden;
}
.qr-stage.muted img {
opacity: 0.35;
}
.qr-stage img {
width: 256px;
height: 256px;
}
.qr-placeholder {
width: 74px;
height: 74px;
color: #1f2937;
}
.qr-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.72);
color: #1f2937;
}
.spin {
animation: spin 1s linear infinite;
}
.login-actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.primary-btn,
.secondary-btn {
min-height: 38px;
padding: 0 14px;
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.primary-btn {
background: var(--color-accent-gradient);
color: white;
}
.secondary-btn {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-secondary);
}
.secondary-btn:hover {
color: var(--color-text-primary);
}
.login-fade-enter-active,
.login-fade-leave-active {
transition: opacity 160ms ease;
}
.login-fade-enter-from,
.login-fade-leave-to {
opacity: 0;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -80,6 +80,34 @@
</div> </div>
<!-- 插件管理 --> <!-- 插件管理 -->
<div v-else-if="activeCategory === 'privacy'" class="section">
<h2 class="section-title">隐私设置</h2>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">允许他人查看我的喜欢和歌单</div>
<div class="setting-desc">关闭后公开歌单和喜欢的歌曲都只对自己可见</div>
</div>
<div class="setting-control">
<label class="toggle-switch" :class="{ 'no-transition': !enableTransition }">
<input type="checkbox" v-model="allowPublicLibrary" :disabled="privacyLoading" @change="onPrivacyChange('library')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">允许他人查看我的个人信息</div>
<div class="setting-desc">关闭后地区性别和生日只对自己可见</div>
</div>
<div class="setting-control">
<label class="toggle-switch" :class="{ 'no-transition': !enableTransition }">
<input type="checkbox" v-model="allowPublicProfile" :disabled="privacyLoading" @change="onPrivacyChange('profile')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div v-else-if="activeCategory === 'plugins'" class="section"> <div v-else-if="activeCategory === 'plugins'" class="section">
<div class="section-header"> <div class="section-header">
<div> <div>
@@ -167,7 +195,7 @@
:key="color.value" :key="color.value"
class="color-swatch" class="color-swatch"
:class="{ active: appearance.accentColor === color.value }" :class="{ active: appearance.accentColor === color.value }"
:style="{ '--swatch-color': color.value }" :style="{ '--swatch-color': color.value, '--swatch-bg': color.gradient || color.value }"
:title="color.name" :title="color.name"
@click="setAccentColor(color.value)" @click="setAccentColor(color.value)"
> >
@@ -202,6 +230,44 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">歌单加载方式</div>
<div class="setting-desc">选择歌单和专辑页面的歌曲加载方式</div>
</div>
<div class="setting-control">
<div class="segmented-control">
<button
class="segment-btn"
:class="{ active: playlistPagingMode === 'infinite' }"
@click="setPlaylistPagingMode('infinite')"
>
下滑加载
</button>
<button
class="segment-btn"
:class="{ active: playlistPagingMode === 'pagination' }"
@click="setPlaylistPagingMode('pagination')"
>
页码分页
</button>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">点击歌曲后打开播放页</div>
<div class="setting-desc">从搜索歌单推荐里点击歌曲播放时自动进入全屏播放页</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" v-model="openPlayerOnSongClick" @change="onOpenPlayerPreferenceChange" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="placeholder-content"> <div class="placeholder-content">
<Icon icon="lucide:headphones" class="placeholder-icon" /> <Icon icon="lucide:headphones" class="placeholder-icon" />
<p>音质淡入淡出等设置即将推出</p> <p>音质淡入淡出等设置即将推出</p>
@@ -209,7 +275,17 @@
</div> </div>
<!-- 快捷键 --> <!-- 快捷键 -->
<div v-else-if="activeCategory === 'shortcuts'" class="section"> <div v-else-if="activeCategory === 'shortcuts'" class="section shortcuts-section">
<h2 class="shortcut-title">快捷键</h2>
<div class="shortcut-list">
<div v-for="item in shortcutRows" :key="item.key" class="shortcut-row">
<div>
<div class="setting-label">{{ item.name }}</div>
<div class="setting-desc">{{ item.desc }}</div>
</div>
<kbd>{{ item.key }}</kbd>
</div>
</div>
<h2 class="section-title">快捷键</h2> <h2 class="section-title">快捷键</h2>
<div class="placeholder-content"> <div class="placeholder-content">
<Icon icon="lucide:keyboard" class="placeholder-icon" /> <Icon icon="lucide:keyboard" class="placeholder-icon" />
@@ -260,6 +336,7 @@ defineEmits(['close']);
const categories = [ const categories = [
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' }, { id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
{ id: 'privacy', name: '隐私', icon: 'lucide:shield' },
{ id: 'plugins', name: '插件', icon: 'lucide:blocks' }, { id: 'plugins', name: '插件', icon: 'lucide:blocks' },
{ id: 'appearance', name: '外观', icon: 'lucide:palette' }, { id: 'appearance', name: '外观', icon: 'lucide:palette' },
{ id: 'playback', name: '播放', icon: 'lucide:headphones' }, { id: 'playback', name: '播放', icon: 'lucide:headphones' },
@@ -268,6 +345,11 @@ const categories = [
]; ];
const accentColors = [ const accentColors = [
{
name: '默认蓝紫',
value: '#8289d3',
gradient: 'linear-gradient(135deg, #b0baeb 0%, #b1bfe9 24%, #b3c9df 48%, #c1c0d3 72%, #dfacb9 100%)',
},
{ name: '红色', value: '#ec4141' }, { name: '红色', value: '#ec4141' },
{ name: '橙色', value: '#f97316' }, { name: '橙色', value: '#f97316' },
{ name: '金色', value: '#eab308' }, { name: '金色', value: '#eab308' },
@@ -282,6 +364,16 @@ const activeCategory = ref('storage');
const isLoaded = ref(false); const isLoaded = ref(false);
const enableTransition = ref(false); const enableTransition = ref(false);
const plugins = ref<any[]>([]); const plugins = ref<any[]>([]);
const playlistPagingMode = ref<'infinite' | 'pagination'>('infinite');
const openPlayerOnSongClick = ref(false);
const allowPublicLibrary = ref(false);
const allowPublicProfile = ref(false);
const privacyLoading = ref(false);
const shortcutRows = [
{ key: 'Space', name: '播放 / 暂停', desc: '在非输入状态下切换当前播放状态' },
{ key: 'A', name: '上一首', desc: '切换到播放队列里的上一首歌曲' },
{ key: 'D', name: '下一首', desc: '切换到播放队列里的下一首歌曲' },
];
const settings = reactive({ const settings = reactive({
persistCache: true, persistCache: true,
@@ -289,7 +381,7 @@ const settings = reactive({
const appearance = reactive({ const appearance = reactive({
theme: 'dark' as 'dark' | 'light', theme: 'dark' as 'dark' | 'light',
accentColor: '#ec4141', accentColor: '#8289d3',
}); });
const cacheInfo = reactive({ const cacheInfo = reactive({
@@ -374,18 +466,58 @@ const loadAppearance = async () => {
if (window.electronAPI?.settings) { if (window.electronAPI?.settings) {
const allSettings = await window.electronAPI.settings.getAll(); const allSettings = await window.electronAPI.settings.getAll();
appearance.theme = allSettings.theme; appearance.theme = allSettings.theme;
appearance.accentColor = allSettings.accentColor; appearance.accentColor = allSettings.accentColor === '#b3c9df' ? '#8289d3' : allSettings.accentColor;
playlistPagingMode.value = allSettings.playlistPagingMode || 'infinite';
openPlayerOnSongClick.value = Boolean(allSettings.openPlayerOnSongClick);
applyTheme(appearance.theme); applyTheme(appearance.theme);
applyAccentColor(appearance.accentColor); applyAccentColor(appearance.accentColor);
} }
}; };
const loadPrivacy = async () => {
if (!window.electronAPI?.privacy?.getLibrary) return;
try {
privacyLoading.value = true;
const data = await window.electronAPI.privacy.getLibrary();
allowPublicLibrary.value = Boolean(data?.allow_public_library);
allowPublicProfile.value = Boolean(data?.allow_public_profile);
} catch (e) {
console.warn('Failed to load privacy settings', e);
} finally {
privacyLoading.value = false;
}
};
const onPrivacyChange = async (target: 'library' | 'profile') => {
if (!window.electronAPI?.privacy?.setLibrary) return;
try {
privacyLoading.value = true;
const payload = target === 'library'
? { allow_public_library: allowPublicLibrary.value }
: { allow_public_profile: allowPublicProfile.value };
const data = await window.electronAPI.privacy.setLibrary(payload);
allowPublicLibrary.value = Boolean(data?.allow_public_library);
allowPublicProfile.value = Boolean(data?.allow_public_profile);
ElMessage.success('隐私设置已更新');
} catch (e) {
ElMessage.error('隐私设置更新失败');
await loadPrivacy();
} finally {
privacyLoading.value = false;
}
};
const applyTheme = (theme: 'dark' | 'light') => { const applyTheme = (theme: 'dark' | 'light') => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
}; };
const applyAccentColor = (color: string) => { const applyAccentColor = (color: string) => {
document.documentElement.style.setProperty('--color-accent', color); document.documentElement.style.setProperty('--color-accent', color);
document.documentElement.style.setProperty('--color-accent-gradient', color);
const atmosphere = color === '#8289d3'
? 'linear-gradient(180deg, rgba(176, 186, 235, 0.36) 0%, rgba(177, 191, 233, 0.31) 18%, rgba(179, 201, 223, 0.25) 38%, rgba(193, 192, 211, 0.18) 58%, rgba(223, 172, 185, 0.11) 78%, transparent 100%)'
: 'linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, color-mix(in srgb, var(--color-accent) 7%, transparent) 44%, transparent 100%)';
document.documentElement.style.setProperty('--color-atmosphere-gradient', atmosphere);
}; };
const setTheme = async (theme: 'dark' | 'light') => { const setTheme = async (theme: 'dark' | 'light') => {
@@ -404,6 +536,23 @@ const setAccentColor = async (color: string) => {
} }
}; };
const setPlaylistPagingMode = async (mode: 'infinite' | 'pagination') => {
playlistPagingMode.value = mode;
if (window.electronAPI?.settings) {
await window.electronAPI.settings.set({ playlistPagingMode: mode });
}
window.dispatchEvent(new CustomEvent('qz-playlist-page-mode-changed', { detail: mode }));
};
const onOpenPlayerPreferenceChange = async () => {
if (window.electronAPI?.settings) {
await window.electronAPI.settings.set({ openPlayerOnSongClick: openPlayerOnSongClick.value });
}
window.dispatchEvent(new CustomEvent('qz-open-player-on-song-click-changed', {
detail: openPlayerOnSongClick.value,
}));
};
const onCacheToggle = async () => { const onCacheToggle = async () => {
if (window.electronAPI) { if (window.electronAPI) {
await window.electronAPI.setCachePersist(settings.persistCache); await window.electronAPI.setCachePersist(settings.persistCache);
@@ -450,7 +599,7 @@ const changeCacheLocation = async () => {
// Load settings BEFORE mount to avoid visual flicker // Load settings BEFORE mount to avoid visual flicker
onBeforeMount(async () => { onBeforeMount(async () => {
await Promise.all([loadCacheInfo(), loadAppearance()]); await Promise.all([loadCacheInfo(), loadAppearance(), loadPrivacy()]);
isLoaded.value = true; isLoaded.value = true;
// Enable transition after initial render // Enable transition after initial render
nextTick(() => { nextTick(() => {
@@ -680,7 +829,7 @@ onBeforeMount(async () => {
} }
input:checked + .toggle-slider { input:checked + .toggle-slider {
background-color: var(--color-accent); background: var(--color-accent-gradient);
} }
input:checked + .toggle-slider:before { input:checked + .toggle-slider:before {
@@ -835,21 +984,24 @@ input:checked + .toggle-slider:before {
height: 36px; height: 36px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
border: 3px solid transparent; border: 3px solid transparent;
background-color: var(--swatch-color); background: var(--swatch-bg);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: border-color 0.18s ease, outline-color 0.18s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
outline: 1px solid color-mix(in srgb, var(--swatch-color) 22%, transparent);
outline-offset: 2px;
} }
.color-swatch:hover { .color-swatch:hover {
transform: scale(1.1); border-color: color-mix(in srgb, var(--swatch-color) 34%, transparent);
} }
.color-swatch.active { .color-swatch.active {
box-shadow: 0 0 16px var(--swatch-color); border-color: var(--color-bg-primary);
outline-color: var(--swatch-color);
} }
.check-icon { .check-icon {
@@ -895,6 +1047,77 @@ input:checked + .toggle-slider:before {
cursor: pointer; cursor: pointer;
} }
.segmented-control {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--radius-full);
background: var(--color-bg-tertiary);
}
.segment-btn {
min-height: 34px;
padding: 0 14px;
border-radius: var(--radius-full);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
transition: background-color 160ms ease, color 160ms ease;
}
.segment-btn:hover {
color: var(--color-text-primary);
}
.segment-btn.active {
background: var(--color-bg-primary);
color: var(--color-accent);
}
.shortcuts-section .placeholder-content {
display: none;
}
.shortcuts-section > .section-title {
display: none;
}
.shortcut-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border);
}
.shortcut-list {
display: grid;
gap: 10px;
}
.shortcut-row {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 0;
border-bottom: 1px solid var(--color-border);
}
kbd {
min-width: 58px;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-md);
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font: 700 13px var(--font-family-base);
}
/* Plugin Card Styles */ /* Plugin Card Styles */
.plugin-list { .plugin-list {
display: flex; display: flex;

View File

@@ -1,94 +1,196 @@
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<!-- 顶部区域 --> <div class="brand">
<div class="sidebar-header"> <div class="brand-mark">
<div class="logo-area"> <Icon icon="lucide:music-2" />
<div class="logo-icon">🎶</div> </div>
<span class="app-name">QZ Music</span> <div class="brand-copy">
<div class="brand-name">QZ Music</div>
<div class="brand-subtitle">Private listening room</div>
</div> </div>
</div> </div>
<!-- 主导航 --> <nav class="nav-section">
<div class="nav-section">
<router-link to="/" class="nav-item" active-class="active"> <router-link to="/" class="nav-item" active-class="active">
<Icon icon="lucide:home" class="nav-icon" /> <Icon icon="lucide:home" />
<span class="nav-text">推荐</span> <span>主页</span>
</router-link> </router-link>
<router-link to="/local" class="nav-item" active-class="active"> <router-link to="/local" class="nav-item" active-class="active">
<Icon icon="lucide:hard-drive" class="nav-icon" /> <Icon icon="lucide:hard-drive" />
<span class="nav-text">本地音乐</span> <span>本地音乐</span>
</router-link> </router-link>
</div>
<!-- 我的音乐 -->
<div class="divider"></div>
<div class="nav-section">
<div class="section-title">我的音乐</div>
<router-link to="/liked" class="nav-item" active-class="active"> <router-link to="/liked" class="nav-item" active-class="active">
<Icon icon="lucide:heart" class="nav-icon" /> <Icon icon="lucide:heart" />
<span class="nav-text">我喜欢的</span> <span>我喜欢的</span>
</router-link> </router-link>
<router-link to="/recent" class="nav-item" active-class="active"> <router-link to="/recent" class="nav-item" active-class="active">
<Icon icon="lucide:clock" class="nav-icon" /> <Icon icon="lucide:clock-3" />
<span class="nav-text">最近播放</span> <span>最近播放</span>
</router-link> </router-link>
<div class="nav-item"> <router-link to="/together" class="nav-item" active-class="active">
<Icon icon="lucide:download" class="nav-icon" /> <Icon icon="lucide:users-round" />
<span class="nav-text">下载管理</span> <span>一起听</span>
</div> </router-link>
</div> <router-link to="/listen-stats" class="nav-item" active-class="active">
<Icon icon="lucide:activity" />
<span>听歌足迹</span>
</router-link>
<router-link to="/listen-rank" class="nav-item" active-class="active">
<Icon icon="lucide:trophy" />
<span>排行</span>
</router-link>
<router-link to="/playlist-square" class="nav-item" active-class="active">
<Icon icon="lucide:layout-grid" />
<span>歌单广场</span>
</router-link>
</nav>
<!-- 我的歌单 --> <div class="section-divider"></div>
<div class="divider"></div>
<div class="nav-section"> <section class="playlist-section">
<div class="section-header" @click="togglePlaylists"> <div class="section-header">
<span class="section-title">我的歌单</span> <button class="section-title" @click="isPlaylistsOpen = !isPlaylistsOpen">
<Icon <Icon icon="lucide:chevron-down" :class="{ collapsed: !isPlaylistsOpen }" />
icon="lucide:chevron-down" <span>我的歌单</span>
class="collapse-icon" </button>
:class="{ 'collapsed': !isPlaylistsOpen }" <div class="playlist-action-wrap">
/> <button class="flat-icon" title="歌单操作" @click.stop="showPlaylistActionMenu = !showPlaylistActionMenu">
<Icon icon="lucide:plus" />
</button>
<div v-if="showPlaylistActionMenu" class="playlist-action-menu" @click.stop>
<button @click="openCreateDialog">
<Icon icon="lucide:list-plus" />
<span>新建歌单</span>
</button>
<button @click="importPlaylistFile">
<Icon icon="lucide:upload" />
<span>导入歌单</span>
</button>
</div>
</div>
</div> </div>
<div class="playlists-list" v-show="isPlaylistsOpen"> <div v-show="isPlaylistsOpen" class="playlist-list">
<div class="nav-item playlist-item"> <router-link
<div class="playlist-cover"> v-for="playlist in playlistStore.all"
<Icon icon="lucide:music" /> :key="`${playlist.scope}:${playlist.id}`"
:to="{ name: 'PlaylistDetail', params: { scope: playlist.scope, id: playlist.id } }"
class="playlist-link"
active-class="active"
>
<div class="mini-cover">
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
<Icon v-else :icon="playlist.scope === 'cloud' ? 'lucide:cloud' : 'lucide:list-music'" />
</div> </div>
<span class="nav-text">驾驶模式</span> <div class="playlist-copy">
</div> <span class="playlist-name">{{ playlist.info.name }}</span>
<div class="nav-item playlist-item"> <span class="playlist-meta">{{ playlist.scope === 'cloud' ? '云端' : '本地' }} · {{ playlist.total }} </span>
<div class="playlist-cover">
<Icon icon="lucide:music" />
</div> </div>
<span class="nav-text">放松时光</span> </router-link>
</div>
<div class="nav-item playlist-item"> <button v-if="playlistStore.all.length === 0" class="empty-create" @click="openCreateDialog">
<div class="playlist-cover"> <Icon icon="lucide:plus" />
<Icon icon="lucide:music" /> <span>创建第一个歌单</span>
</div> </button>
<span class="nav-text">工作专注</span>
</div>
<div class="nav-item create-playlist">
<Icon icon="lucide:plus" class="nav-icon" />
<span class="nav-text">新建歌单</span>
</div>
</div> </div>
</div> </section>
<Teleport to="body">
<Transition name="fade">
<div v-if="showCreateDialog" class="dialog-backdrop" @click.self="showCreateDialog = false">
<div class="create-dialog">
<div class="dialog-title">新建歌单</div>
<input v-model="draftName" class="text-input" placeholder="歌单名称" />
<textarea v-model="draftDesc" class="text-area" placeholder="简介,可选"></textarea>
<div class="scope-tabs">
<button :class="{ active: draftScope === 'local' }" @click="draftScope = 'local'">
<Icon icon="lucide:hard-drive" />
本地
</button>
<button :class="{ active: draftScope === 'cloud' }" :disabled="!authStore.isLoggedIn" @click="draftScope = 'cloud'">
<Icon icon="lucide:cloud" />
云端
</button>
</div>
<label v-if="draftScope === 'cloud'" class="public-option">
<input type="checkbox" v-model="draftIsPublic" />
<span>公开歌单</span>
</label>
<div class="dialog-actions">
<button class="ghost-btn" @click="showCreateDialog = false">取消</button>
<button class="primary-btn" :disabled="!draftName.trim()" @click="createPlaylist">创建</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</aside> </aside>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue'
import { Icon } from '@iconify/vue'; import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth'
import { usePlaylistsStore, type PlaylistScope } from '../stores/playlists'
const isPlaylistsOpen = ref(true); const router = useRouter()
const authStore = useAuthStore()
const playlistStore = usePlaylistsStore()
const togglePlaylists = () => { const isPlaylistsOpen = ref(true)
isPlaylistsOpen.value = !isPlaylistsOpen.value; const showCreateDialog = ref(false)
}; const showPlaylistActionMenu = ref(false)
const draftName = ref('')
const draftDesc = ref('')
const draftScope = ref<PlaylistScope>('local')
const draftIsPublic = ref(false)
const closePlaylistActionMenu = (event?: MouseEvent) => {
const target = event?.target as HTMLElement | null
if (target?.closest('.playlist-action-wrap')) return
showPlaylistActionMenu.value = false
}
const openCreateDialog = () => {
showPlaylistActionMenu.value = false
draftName.value = ''
draftDesc.value = ''
draftScope.value = 'local'
draftIsPublic.value = false
showCreateDialog.value = true
}
const importPlaylistFile = async () => {
showPlaylistActionMenu.value = false
const result = await playlistStore.importPlaylist()
if (result?.success && result.playlist) {
router.push({ name: 'PlaylistDetail', params: { scope: result.playlist.scope, id: result.playlist.id } })
}
}
const createPlaylist = async () => {
if (draftScope.value === 'cloud' && !authStore.isLoggedIn) {
ElMessage.warning('请先登录后再创建云端歌单')
return
}
const playlist = await playlistStore.create(draftScope.value, {
name: draftName.value,
desc: draftDesc.value,
is_public: draftScope.value === 'cloud' ? draftIsPublic.value : false,
})
showCreateDialog.value = false
router.push({ name: 'PlaylistDetail', params: { scope: playlist.scope, id: playlist.id } })
}
onMounted(() => {
window.addEventListener('click', closePlaylistActionMenu)
})
onUnmounted(() => {
window.removeEventListener('click', closePlaylistActionMenu)
})
</script> </script>
<style scoped> <style scoped>
@@ -96,207 +198,386 @@ const togglePlaylists = () => {
box-sizing: border-box; box-sizing: border-box;
width: var(--sidebar-width); width: var(--sidebar-width);
height: 100vh; height: 100vh;
background-color: var(--color-bg-secondary); background:
border-right: 1px solid var(--color-border); linear-gradient(180deg, color-mix(in srgb, var(--color-bg-secondary) 94%, transparent), color-mix(in srgb, var(--color-bg-primary) 86%, transparent));
border-right: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px 12px; padding: 24px 18px;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0; flex-shrink: 0;
position: relative;
isolation: isolate;
} }
/* 滚动条样式 - 默认隐藏,悬停时显示 */ .sidebar::before {
.sidebar::-webkit-scrollbar { content: '';
width: 6px; position: absolute;
inset: 0 0 auto;
height: 220px;
background: var(--color-atmosphere-gradient);
opacity: 0.95;
pointer-events: none;
z-index: 0;
} }
.sidebar::-webkit-scrollbar-track { .sidebar > * {
background: transparent; position: relative;
z-index: 1;
} }
.sidebar::-webkit-scrollbar-thumb { .brand {
background: transparent;
border-radius: 3px;
transition: background 0.2s ease;
}
.sidebar:hover::-webkit-scrollbar-thumb {
background: var(--color-border-light);
}
.sidebar:hover::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* 顶部Logo区域 */
.sidebar-header {
padding: 8px 8px 24px;
margin-bottom: 8px;
}
.logo-area {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 14px;
padding: 8px; padding: 2px 10px 26px;
border-radius: var(--radius-lg);
transition: all var(--transition-base);
} }
.logo-area:hover { .brand-mark {
background-color: var(--color-bg-tertiary); width: 48px;
height: 48px;
border-radius: 18px;
background:
linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent),
var(--color-bg-primary);
color: var(--color-accent);
display: grid;
place-items: center;
} }
.logo-icon { .brand-mark svg {
width: 40px; width: 23px;
height: 40px; height: 23px;
background: linear-gradient(135deg, #ec4141, #ff6b6b);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
box-shadow: var(--shadow-sm);
} }
.app-name { .brand-copy {
font-size: var(--font-size-lg); min-width: 0;
font-weight: 600; }
.brand-name {
font-size: 17px;
font-weight: 760;
color: var(--color-text-primary); color: var(--color-text-primary);
letter-spacing: -0.02em;
} }
/* 导航区域 */ .brand-subtitle {
.nav-section { margin-top: 4px;
margin-bottom: 8px; font-size: 11px;
color: var(--color-text-muted);
}
.nav-section,
.playlist-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.nav-item,
.playlist-link,
.empty-create,
.section-title {
display: flex;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 0 11px;
border-radius: 14px;
color: var(--color-text-secondary);
text-decoration: none;
transition: background-color 160ms ease, color 160ms ease;
} }
.nav-item { .nav-item {
display: flex; font-size: 13px;
align-items: center; font-weight: 560;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-base);
text-decoration: none;
position: relative;
overflow: hidden;
} }
.nav-item:hover { .nav-item svg,
background-color: var(--color-bg-tertiary); .section-title svg,
.flat-icon svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.flat-icon:hover,
.nav-item:hover,
.playlist-link:hover,
.empty-create:hover,
.section-title:hover {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.nav-item.active { .nav-item.active,
background-color: var(--color-accent-soft); .playlist-link.active {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent); color: var(--color-accent);
font-weight: 500;
} }
.section-divider {
.nav-icon {
width: 20px;
height: 20px;
margin-right: 12px;
flex-shrink: 0;
transition: transform var(--transition-base);
}
.nav-text {
font-size: var(--font-size-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* 分割线 */
.divider {
height: 1px; height: 1px;
background: linear-gradient(to right, transparent, var(--color-border), transparent); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 18%, transparent), transparent);
margin: 16px 8px; margin: 18px 8px;
opacity: 0.6;
flex-shrink: 0;
}
/* 区域标题 */
.section-title {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 16px;
} }
.section-header { .section-header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 16px; justify-content: space-between;
margin-bottom: 8px; gap: 8px;
cursor: pointer; margin-bottom: 7px;
}
.section-title {
flex: 1;
min-height: 32px;
padding: 0 10px;
font-size: 11px;
font-weight: 760;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all var(--transition-base);
border-radius: var(--radius-md);
} }
.section-header:hover { .section-title svg {
color: var(--color-text-primary); transition: transform 160ms ease;
background-color: var(--color-bg-tertiary);
} }
.collapse-icon { .section-title svg.collapsed {
transition: transform var(--transition-base);
width: 16px;
height: 16px;
}
.collapse-icon.collapsed {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
/* 歌单项 */ .flat-icon {
.playlist-item { width: 30px;
padding: 10px 16px; height: 30px;
} border-radius: 50%;
display: grid;
.playlist-cover { place-items: center;
width: 36px; color: var(--color-text-secondary);
height: 36px; transition: background-color 160ms ease, color 160ms ease;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: white;
font-size: 14px;
box-shadow: var(--shadow-sm);
flex-shrink: 0; flex-shrink: 0;
} }
.playlist-item:hover .playlist-cover { .playlist-action-wrap {
transform: scale(1.05); position: relative;
box-shadow: var(--shadow-md); flex-shrink: 0;
} }
/* 新建歌单 */ .playlist-action-menu {
.create-playlist { position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 150px;
padding: 6px;
border-radius: 16px;
background: color-mix(in srgb, var(--color-bg-primary) 94%, transparent);
box-shadow: var(--shadow-elevated);
}
.playlist-action-menu button {
width: 100%;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 12px;
}
.playlist-action-menu button:hover {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
color: var(--color-text-primary);
}
.playlist-action-menu svg {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.playlist-link {
min-height: 48px;
padding: 0 10px;
}
.mini-cover {
width: 32px;
height: 32px;
border-radius: 11px;
display: grid;
place-items: center;
overflow: hidden;
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
color: var(--color-text-muted); color: var(--color-text-muted);
margin-top: 8px; flex-shrink: 0;
border: 1px dashed var(--color-border-light);
} }
.create-playlist:hover { .mini-cover img {
border-color: var(--color-accent); width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-copy {
min-width: 0;
flex: 1;
}
.playlist-name,
.playlist-meta {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.playlist-name {
font-size: 12px;
color: var(--color-text-primary);
}
.playlist-meta {
margin-top: 3px;
font-size: 10px;
color: var(--color-text-muted);
}
.empty-create {
width: 100%;
justify-content: center;
color: var(--color-text-muted);
font-size: 12px;
}
.login-btn,
.primary-btn,
.ghost-btn {
min-height: 34px;
padding: 0 14px;
border-radius: var(--radius-full);
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
}
.login-btn,
.primary-btn {
background: var(--color-accent-gradient);
color: white;
}
.primary-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ghost-btn {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-secondary);
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
}
.create-dialog {
width: min(380px, calc(100vw - 32px));
padding: 22px;
border-radius: 24px;
background: var(--color-bg-primary);
box-shadow: var(--shadow-elevated);
}
.dialog-title {
font-size: 18px;
font-weight: 750;
margin-bottom: 16px;
}
.text-input,
.text-area {
width: 100%;
border: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
background: var(--color-bg-secondary);
border-radius: 16px;
outline: none;
padding: 12px 14px;
margin-bottom: 10px;
}
.text-area {
min-height: 88px;
resize: none;
}
.text-input:focus,
.text-area:focus {
border-color: color-mix(in srgb, var(--color-accent) 34%, transparent);
}
.scope-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 4px 0 18px;
}
.scope-tabs button {
min-height: 42px;
border-radius: 14px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.scope-tabs button.active {
background: var(--color-accent-soft);
color: var(--color-accent); color: var(--color-accent);
background-color: var(--color-accent-soft); }
.scope-tabs button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.public-option {
min-height: 32px;
margin: -4px 0 16px;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 13px;
}
.public-option input {
width: 15px;
height: 15px;
accent-color: var(--color-accent);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 180ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
} }
</style> </style>

View File

@@ -0,0 +1,425 @@
<template>
<div class="song-tile" :class="{ 'with-action': removable || reserveAction }" @click="$emit('play')" @contextmenu.prevent="openMenu">
<div class="song-index">{{ displayIndex }}</div>
<div class="song-cover">
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" alt="" />
<Icon v-else icon="lucide:music" />
</div>
<div class="song-info">
<h4 class="song-title" v-html="renderText(song.name)"></h4>
<p class="song-artist" v-html="renderText(song.artist)"></p>
</div>
<div class="song-album" v-html="renderText(song.albumName || '-')"></div>
<div class="song-duration">{{ song.duration || '--:--' }}</div>
<div v-if="removable || reserveAction" class="song-action">
<button v-if="removable" class="remove-btn" title="移出歌单" @click.stop="$emit('remove')">
<Icon icon="lucide:x" />
</button>
</div>
<Teleport to="body">
<Transition name="menu-fade">
<div
v-if="menuOpen"
ref="menuRef"
class="song-menu"
:style="{ left: `${menuPosition.x}px`, top: `${menuPosition.y}px` }"
@click.stop
>
<button class="menu-row" @click="playNext">
<Icon icon="lucide:list-start" />
<span>下一首播放</span>
</button>
<button class="menu-row" @click="appendToQueue">
<Icon icon="lucide:list-plus" />
<span>添加到播放列表末</span>
</button>
<div class="menu-divider"></div>
<div class="menu-label">添加到歌单</div>
<button
v-for="playlist in writablePlaylists"
:key="`${playlist.scope}:${playlist.id}`"
class="menu-row"
:disabled="addingPlaylistKey === `${playlist.scope}:${playlist.id}`"
@click="addToPlaylist(playlist.scope, playlist.id)"
>
<div class="playlist-menu-cover">
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
<Icon v-else :icon="playlist.scope === 'cloud' ? 'lucide:cloud' : 'lucide:hard-drive'" />
<span class="playlist-source-badge" :class="playlist.scope === 'cloud' ? 'cloud' : 'local'">
{{ playlist.scope === 'cloud' ? '云' : '本' }}
</span>
<span v-if="addingPlaylistKey === `${playlist.scope}:${playlist.id}`" class="cover-loading">
<Icon icon="lucide:loader-2" class="spin" />
</span>
</div>
<span>{{ playlist.info.name }}</span>
</button>
<div v-if="writablePlaylists.length === 0" class="menu-empty">暂无歌单</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, toRaw } from 'vue'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import type { Song } from '../types/song'
import { usePlayerStore } from '../stores/player'
import { usePlaylistsStore, type AppPlaylist, type ManagedPlaylistScope } from '../stores/playlists'
const props = defineProps<{
song: Song
displayIndex: number
highlight?: (text: string) => string
removable?: boolean
reserveAction?: boolean
}>()
defineEmits<{
play: []
remove: []
}>()
const playerStore = usePlayerStore()
const playlistStore = usePlaylistsStore()
const writablePlaylists = computed(() => (
playlistStore.all.filter((playlist) => playlist.scope !== 'plugin') as Array<AppPlaylist & { scope: ManagedPlaylistScope }>
))
const menuOpen = ref(false)
const menuRef = ref<HTMLElement | null>(null)
const menuPosition = ref({ x: 0, y: 0 })
const addingPlaylistKey = ref('')
const renderText = (text: string) => props.highlight ? props.highlight(text) : text
const toPlainSong = (song: Song): Song => {
const raw = toRaw(song) as Song & Record<string, any>
return {
id: String(raw.id ?? ''),
hash: raw.hash ?? null,
picUrl: String(raw.picUrl ?? ''),
url: String(raw.url ?? ''),
name: String(raw.name ?? ''),
artist: String(raw.artist ?? ''),
duration: String(raw.duration ?? ''),
source: String(raw.source ?? ''),
lyric: typeof raw.lyric === 'string' ? raw.lyric : undefined,
quality: raw.quality,
albumId: raw.albumId ?? null,
albumName: raw.albumName ?? null,
artistIds: Array.isArray(raw.artistIds) ? raw.artistIds.map(String) : null,
type: raw.type,
types: raw.types && typeof raw.types === 'object' ? { ...raw.types } : undefined,
}
}
const closeMenu = () => {
menuOpen.value = false
window.removeEventListener('click', closeMenu)
window.removeEventListener('scroll', closeMenu, true)
window.removeEventListener('resize', closeMenu)
}
const clampMenuPosition = async () => {
await nextTick()
const menu = menuRef.value
if (!menu) return
const rect = menu.getBoundingClientRect()
const margin = 12
menuPosition.value = {
x: Math.min(menuPosition.value.x, window.innerWidth - rect.width - margin),
y: Math.min(menuPosition.value.y, window.innerHeight - rect.height - margin),
}
}
const openMenu = async (event: MouseEvent) => {
menuPosition.value = { x: event.clientX, y: event.clientY }
menuOpen.value = true
window.addEventListener('click', closeMenu)
window.addEventListener('scroll', closeMenu, true)
window.addEventListener('resize', closeMenu)
playlistStore.refresh().catch((error) => console.warn('[SongTile] Failed to refresh playlists:', error))
await clampMenuPosition()
}
const playNext = async () => {
await playerStore.playNextInQueue(toPlainSong(props.song))
closeMenu()
}
const appendToQueue = async () => {
await playerStore.appendToQueue(toPlainSong(props.song))
closeMenu()
}
const addToPlaylist = async (scope: ManagedPlaylistScope, id: string) => {
const key = `${scope}:${id}`
if (addingPlaylistKey.value) return
addingPlaylistKey.value = key
try {
await playlistStore.addSong(scope, id, toPlainSong(props.song))
closeMenu()
} catch (error: any) {
console.error('[SongTile] add to playlist failed:', error)
ElMessage.error(error?.message || '添加到歌单失败')
} finally {
addingPlaylistKey.value = ''
}
}
onBeforeUnmount(closeMenu)
</script>
<style scoped>
.song-tile {
box-sizing: border-box;
width: 100%;
min-height: 62px;
display: grid;
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px;
gap: 16px;
align-items: center;
padding: 10px 16px;
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.song-tile.with-action {
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px 42px;
}
.song-tile:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.song-index {
text-align: center;
font-size: 14px;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.song-cover {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
display: grid;
place-items: center;
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
.song-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-info {
min-width: 0;
}
.song-title,
.song-artist,
.song-album {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.song-title {
font-size: 15px;
color: var(--color-text-primary);
margin: 0 0 2px;
font-weight: 650;
}
.song-artist {
margin: 0;
font-size: 12px;
color: var(--color-text-muted);
}
.song-album {
font-size: 13px;
color: var(--color-text-muted);
}
.song-duration {
text-align: right;
font-size: 13px;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.song-action {
display: flex;
justify-content: center;
}
.remove-btn {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--color-text-secondary);
opacity: 0;
transition: opacity 160ms ease, background-color 160ms ease, color 160ms ease;
}
.song-tile:hover .remove-btn,
.remove-btn:focus-visible {
opacity: 1;
}
.remove-btn:hover {
background: rgba(255, 95, 95, 0.14);
color: #ff7070;
}
.song-menu {
position: fixed;
width: 232px;
max-height: min(360px, calc(100vh - 24px));
overflow-y: auto;
padding: 7px;
border-radius: 18px;
background: color-mix(in srgb, var(--color-bg-primary) 96%, white);
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
box-shadow: var(--shadow-elevated);
z-index: 10000;
}
.menu-row {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 10px;
border-radius: 12px;
color: var(--color-text-secondary);
text-align: left;
transition: background-color 160ms ease, color 160ms ease;
}
.menu-row:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.menu-row:disabled {
opacity: 0.62;
cursor: wait;
}
.menu-row span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.playlist-menu-cover {
width: 28px;
height: 28px;
border-radius: 8px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
flex: 0 0 auto;
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
.playlist-menu-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-menu-cover > svg {
width: 16px;
height: 16px;
}
.playlist-source-badge {
position: absolute;
right: -1px;
bottom: -1px;
min-width: 14px;
height: 14px;
padding: 0 2px;
border-radius: 6px 0 8px 0;
display: grid;
place-items: center;
color: #fff;
font-size: 9px;
font-weight: 800;
line-height: 1;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-bg-primary) 90%, transparent);
}
.playlist-source-badge.cloud {
background: #4d8dff;
}
.playlist-source-badge.local {
background: #21a56b;
}
.cover-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
background: rgba(0, 0, 0, 0.45);
}
.menu-divider {
height: 1px;
margin: 6px 6px;
background: color-mix(in srgb, var(--color-border) 80%, transparent);
}
.menu-label,
.menu-empty {
padding: 7px 10px;
font-size: 12px;
color: var(--color-text-muted);
}
.menu-label {
font-weight: 700;
}
.menu-fade-enter-active,
.menu-fade-leave-active {
transition: opacity 150ms ease, transform 150ms ease;
}
.menu-fade-enter-from,
.menu-fade-leave-to {
opacity: 0;
transform: translateY(3px);
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -2,11 +2,11 @@
<header class="topbar"> <header class="topbar">
<div class="left-controls"> <div class="left-controls">
<div class="nav-group"> <div class="nav-group">
<button class="nav-btn ripple-btn" @click="goBack" title="返回"> <button class="nav-btn" @click="goBack" title="返回">
<Icon icon="lucide:chevron-left" class="nav-icon" /> <Icon icon="lucide:chevron-left" />
</button> </button>
<button class="nav-btn ripple-btn" @click="goForward" title="前进"> <button class="nav-btn" @click="goForward" title="前进">
<Icon icon="lucide:chevron-right" class="nav-icon" /> <Icon icon="lucide:chevron-right" />
</button> </button>
</div> </div>
@@ -14,36 +14,63 @@
<div class="search-container"> <div class="search-container">
<Icon icon="lucide:search" class="search-icon" /> <Icon icon="lucide:search" class="search-icon" />
<input <input
type="text" type="text"
placeholder="搜索音乐、歌手、专辑..." placeholder="搜索音乐、歌手、专辑..."
class="search-input" class="search-input"
v-model="searchQuery" v-model="searchQuery"
@keydown.enter="handleSearch" @keydown.enter="handleSearch"
/> />
</div> </div>
</div> </div>
</div> </div>
<div class="right-controls"> <div class="right-controls">
<div class="app-actions"> <div class="user-menu-wrap">
<button class="action-btn ripple-btn" title="设置" @click="openSettings"> <button
<Icon icon="lucide:settings" class="action-icon" /> class="user-chip"
@click="handleUserClick"
@contextmenu.prevent="openUserMenu"
@mousedown.left="startUserPress"
@mouseup="cancelUserPress"
@mouseleave="cancelUserPress"
:title="authStore.isLoggedIn ? '账号' : '登录'"
>
<img v-if="authStore.avatar" :src="authStore.avatar" class="user-avatar" alt="" />
<span v-else class="user-avatar fallback">
<Icon icon="lucide:user" />
</span>
<span class="user-copy">
<strong>{{ authStore.displayName }}</strong>
<small>{{ authStore.isLoggedIn ? '云端已连接' : '点击登录' }}</small>
</span>
</button> </button>
<div v-if="showUserMenu" class="user-menu" @click.stop>
<button @click="openProfileEdit">
<Icon icon="lucide:pencil" />
<span>编辑资料</span>
</button>
<button class="danger" @click="logout">
<Icon icon="lucide:log-out" />
<span>退出账号</span>
</button>
</div>
</div> </div>
<button class="action-btn" title="设置" @click="openSettings">
<Icon icon="lucide:settings" />
</button>
<div class="divider"></div> <div class="divider"></div>
<div class="window-actions"> <div class="window-actions">
<button class="win-btn minimize" @click="handleMinimize" title="最小化"> <button class="win-btn" @click="handleMinimize" title="最小化">
<Icon icon="lucide:minus" class="win-icon" /> <Icon icon="lucide:minus" />
</button> </button>
<button class="win-btn" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
<button class="win-btn maximize" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'"> <Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" />
<Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" class="win-icon" style="transform: scale(0.8);" />
</button> </button>
<button class="win-btn close" @click="handleClose" title="关闭"> <button class="win-btn close" @click="handleClose" title="关闭">
<Icon icon="lucide:x" class="win-icon" /> <Icon icon="lucide:x" />
</button> </button>
</div> </div>
</div> </div>
@@ -51,282 +78,357 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, inject } from 'vue'; import { ref, onMounted, onUnmounted, inject } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter(); const router = useRouter()
const isMaximized = ref(false); const authStore = useAuthStore()
const searchQuery = ref(''); const isMaximized = ref(false)
const searchQuery = ref('')
const showUserMenu = ref(false)
const ignoreNextUserClick = ref(false)
let userPressTimer: number | undefined
const goBack = () => router.back(); const goBack = () => router.back()
const goForward = () => router.forward(); const goForward = () => router.forward()
const handleSearch = () => { const handleSearch = () => {
if (!searchQuery.value.trim()) return; if (!searchQuery.value.trim()) return
router.push({ router.push({
name: 'Search', name: 'Search',
query: { q: searchQuery.value } query: { q: searchQuery.value }
}); })
}; }
// Settings const openSettings = inject<() => void>('openSettings', () => {})
const openSettings = inject<() => void>('openSettings', () => {}); const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
// --- 窗口控制逻辑 --- const handleUserClick = () => {
const handleMinimize = () => window.electronAPI?.minimizeWindow(); if (ignoreNextUserClick.value) {
ignoreNextUserClick.value = false
return
}
if (!authStore.isLoggedIn) {
openLoginDialog()
return
}
router.push({ name: 'UserProfile', params: { id: authStore.state.userInfo?.id } })
}
const openUserMenu = () => {
if (!authStore.isLoggedIn) return
showUserMenu.value = true
}
const startUserPress = () => {
if (!authStore.isLoggedIn) return
cancelUserPress()
userPressTimer = window.setTimeout(() => {
ignoreNextUserClick.value = true
showUserMenu.value = true
}, 480)
}
const cancelUserPress = () => {
if (userPressTimer) {
window.clearTimeout(userPressTimer)
userPressTimer = undefined
}
}
const closeUserMenu = (event?: MouseEvent) => {
const target = event?.target as HTMLElement | null
if (target?.closest('.user-menu-wrap')) return
showUserMenu.value = false
}
const openProfileEdit = () => {
showUserMenu.value = false
if (!authStore.state.userInfo?.id) return
router.push({ name: 'UserProfile', params: { id: authStore.state.userInfo.id }, query: { edit: '1' } })
}
const logout = async () => {
showUserMenu.value = false
await authStore.logout()
}
const handleMinimize = () => window.electronAPI?.minimizeWindow()
const handleMaximize = async () => { const handleMaximize = async () => {
window.electronAPI?.maximizeWindow(); window.electronAPI?.maximizeWindow()
isMaximized.value = !isMaximized.value; isMaximized.value = !isMaximized.value
checkMaximizedState(); checkMaximizedState()
}; }
const handleClose = () => window.electronAPI?.closeWindow(); const handleClose = () => window.electronAPI?.closeWindow()
const checkMaximizedState = async () => { const checkMaximizedState = async () => {
if (window.electronAPI) { if (window.electronAPI) {
setTimeout(async () => { setTimeout(async () => {
isMaximized.value = await window.electronAPI!.isMaximized(); isMaximized.value = await window.electronAPI!.isMaximized()
}, 100); }, 100)
} }
}; }
onMounted(() => { onMounted(() => {
checkMaximizedState(); checkMaximizedState()
window.addEventListener('resize', checkMaximizedState); window.addEventListener('resize', checkMaximizedState)
}); window.addEventListener('click', closeUserMenu)
})
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', checkMaximizedState); cancelUserPress()
}); window.removeEventListener('resize', checkMaximizedState)
window.removeEventListener('click', closeUserMenu)
})
</script> </script>
<style scoped> <style scoped>
/* 变量定义 */
:root {
--radius-soft: 10px;
}
.topbar { .topbar {
box-sizing: border-box; box-sizing: border-box;
height: 64px; height: 72px;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 16px 0 24px; padding: 0 16px 0 24px;
background-color: var(--color-bg-primary); background: transparent;
border-bottom: 1px solid var(--color-border); border-bottom: none;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
-webkit-app-region: drag; -webkit-app-region: drag;
user-select: none; user-select: none;
backdrop-filter: none;
} }
/* --- 左侧区域 --- */ .left-controls,
.left-controls { .right-controls,
.nav-group,
.window-actions {
display: flex; display: flex;
align-items: center; align-items: center;
}
.left-controls {
gap: 16px; gap: 16px;
min-width: 0;
}
.right-controls {
gap: 3px;
height: 100%;
} }
.nav-group { .nav-group {
display: flex; gap: 8px;
gap: 10px; }
.nav-btn,
.action-btn,
.win-btn {
-webkit-app-region: no-drag;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--color-text-secondary);
background: transparent;
transition: background-color 160ms ease, color 160ms ease;
} }
/* --- 统一功能按钮Nav / Settings--- */
.nav-btn, .nav-btn,
.action-btn { .action-btn {
-webkit-app-region: no-drag; width: 36px;
width: 40px; height: 36px;
height: 40px;
border-radius: 10px; /* 圆角边框 */
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
background-color: transparent;
cursor: pointer;
position: relative;
overflow: hidden; /* 关键:裁剪涟漪 */
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.12s ease;
} }
.nav-btn svg,
.action-btn svg {
width: 18px;
height: 18px;
}
.nav-btn:hover, .nav-btn:hover,
.action-btn:hover { .action-btn:hover,
background-color: var(--color-bg-tertiary); .win-btn:not(.close):hover {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
/* 图标 */ .user-chip:hover {
.nav-icon, color: var(--color-text-primary);
.action-icon {
width: 22px;
height: 22px;
z-index: 2;
} }
.ripple-btn::after { .search-wrapper,
content: ""; .user-menu-wrap,
position: absolute; .user-chip {
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.1); /* 涟漪颜色 */
border-radius: 50%;
transform: translate(-50%, -50%) scale(0); /* 初始不可见 */
opacity: 0;
pointer-events: none;
}
/* 点击瞬间:迅速放大并显示 */
.ripple-btn:active::after {
transform: translate(-50%, -50%) scale(2.5);
opacity: 1;
transition: 0s;
}
/* 松开后:慢慢淡出 */
.ripple-btn:not(:active):after {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
transition: opacity 0.4s ease-out;
}
/* --- 搜索框 --- */
.search-wrapper {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.user-menu-wrap {
position: relative;
}
.search-container { .search-container {
display: flex; display: flex;
align-items: center; align-items: center;
height: 40px; height: 40px;
width: 260px; width: 286px;
padding: 0 14px; padding: 0 14px;
background-color: var(--color-bg-tertiary); background: color-mix(in srgb, var(--color-bg-secondary) 72%, transparent);
border: 1px solid var(--color-border); border: 1px solid transparent;
border-radius: 20px; border-radius: var(--radius-full);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); transition: background-color 180ms ease, border-color 180ms ease, width 180ms ease;
cursor: text; cursor: text;
} }
.search-container:hover { .search-container:hover,
border-color: var(--color-text-muted); .search-container:focus-within {
border-color: color-mix(in srgb, var(--color-accent) 26%, transparent);
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
} }
.search-container:focus-within { .search-container:focus-within {
width: 340px; width: 352px;
border-color: var(--color-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
background: linear-gradient(
90deg,
var(--color-bg-primary) 0%,
var(--color-bg-tertiary) 100%
);
} }
.search-icon { .search-icon {
color: var(--color-text-muted); color: var(--color-text-muted);
margin-right: 10px; margin-right: 10px;
width: 20px; width: 18px;
height: 20px; height: 18px;
flex-shrink: 0; flex-shrink: 0;
transition: color 0.2s;
}
.search-container:focus-within .search-icon {
color: var(--color-accent);
} }
.search-input { .search-input {
flex: 1; flex: 1;
height: 100%; height: 100%;
font-size: 15px; font-size: 14px;
color: var(--color-text-primary); color: var(--color-text-primary);
background: transparent;
border: none;
outline: none; outline: none;
} }
.search-input::placeholder { .search-input::placeholder {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 14px;
} }
/* --- 右侧区域布局调整 --- */ .user-chip {
.right-controls { height: 44px;
padding: 0 8px 0 5px;
border-radius: var(--radius-full);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; /* 减少间距,让设置按钮更靠近右边 */ gap: 10px;
height: 100%; color: var(--color-text-secondary);
transition: background-color 160ms ease, color 160ms ease;
} }
.app-actions { .user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.user-avatar.fallback {
display: grid;
place-items: center;
background: var(--color-accent-soft);
color: var(--color-accent);
}
.user-copy {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.user-copy strong,
.user-copy small {
max-width: 112px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.user-copy strong {
font-size: 12px;
color: var(--color-text-primary);
}
.user-copy small {
margin-top: 3px;
font-size: 10px;
color: var(--color-text-muted);
}
.user-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 150;
width: 142px;
padding: 6px;
border-radius: 16px;
background: color-mix(in srgb, var(--color-bg-primary) 94%, transparent);
box-shadow: var(--shadow-elevated);
border: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.user-menu button {
width: 100%;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 12px;
}
.user-menu button:hover {
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
color: var(--color-text-primary);
}
.user-menu button.danger:hover {
color: #ff6b6b;
}
.user-menu svg {
width: 15px;
height: 15px;
} }
.divider { .divider {
width: 1px; width: 1px;
height: 24px; height: 24px;
background-color: var(--color-border); background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--color-accent) 24%, transparent), transparent);
margin: 0 4px; /* 减少分割线左右的间距 */ margin: 0 6px;
} }
/* --- 窗口控制区 --- */
.window-actions { .window-actions {
display: flex; gap: 5px;
align-items: center;
gap: 6px;
} }
.win-btn { .win-btn {
-webkit-app-region: no-drag;
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
color: var(--color-text-primary);
border: none;
cursor: pointer;
transition: all 0.1s;
} }
.win-icon { .win-btn svg {
width: 16px; width: 15px;
height: 16px; height: 15px;
}
.win-btn:not(.close):hover {
background-color: var(--color-bg-tertiary);
}
.win-btn:not(.close):active {
background-color: var(--color-bg-elevated);
} }
.win-btn.close:hover { .win-btn.close:hover {
background-color: #e81123; background: #e81123;
color: white; color: white;
} }
.win-btn.close:active {
background-color: #bf0f1d;
}
</style> </style>

View File

@@ -23,7 +23,7 @@
<script setup lang="ts"> <script setup lang="ts">
import TextMarquee from './TextMarquee.vue'; import TextMarquee from './TextMarquee.vue';
const props = defineProps<{ defineProps<{
name?: string; name?: string;
artists?: string[]; artists?: string[];
album?: string; album?: string;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
<template>
<div class="queue-list" :class="variant">
<div
v-for="(song, index) in playlist"
:key="`${song.id}:${index}`"
class="queue-row"
:class="{ active: index === currentIndex, dragging: index === draggingIndex }"
:data-queue-index="index"
role="button"
tabindex="0"
@click="playQueueItem(index)"
@keydown.enter="playQueueItem(index)"
@keydown.space.prevent="playQueueItem(index)"
@pointerdown="startPress($event, index)"
>
<div class="queue-index">
<span v-if="index === currentIndex" class="playing-indicator">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</span>
<span v-else>{{ index + 1 }}</span>
</div>
<img v-if="song.picUrl" :src="song.picUrl" class="queue-cover" alt="" />
<div v-else class="queue-cover queue-cover-placeholder">
<Icon icon="lucide:music" />
</div>
<div class="queue-info">
<div class="queue-name">{{ song.name }}</div>
<div class="queue-artist">{{ song.artist }}</div>
</div>
<div class="queue-duration">{{ song.duration || '--:--' }}</div>
<button class="queue-remove" title="移出播放列表" @click.stop="removeQueueItem(index)">
<Icon icon="lucide:x" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { Icon } from '@iconify/vue'
import { usePlayerStore } from '../../stores/player'
withDefaults(defineProps<{
variant?: 'popover' | 'fullscreen'
}>(), {
variant: 'popover',
})
const playerStore = usePlayerStore()
const { playlist, currentIndex } = storeToRefs(playerStore)
const draggingIndex = ref<number | null>(null)
const suppressClick = ref(false)
let pressTimer: number | undefined
const clearPressTimer = () => {
if (pressTimer !== undefined) {
window.clearTimeout(pressTimer)
pressTimer = undefined
}
}
const startPress = (event: PointerEvent, index: number) => {
const target = event.target as HTMLElement
if (target.closest('.queue-remove')) return
clearPressTimer()
pressTimer = window.setTimeout(() => {
draggingIndex.value = index
suppressClick.value = true
document.body.classList.add('queue-dragging')
window.addEventListener('pointermove', handleDragMove)
window.addEventListener('pointerup', stopDrag, { once: true })
window.addEventListener('pointercancel', stopDrag, { once: true })
}, 260)
window.addEventListener('pointerup', clearPressTimer, { once: true })
window.addEventListener('pointercancel', clearPressTimer, { once: true })
}
const handleDragMove = (event: PointerEvent) => {
if (draggingIndex.value === null) return
const element = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null
const row = element?.closest<HTMLElement>('[data-queue-index]')
if (!row) return
const nextIndex = Number(row.dataset.queueIndex)
if (!Number.isInteger(nextIndex) || nextIndex === draggingIndex.value) return
playerStore.moveQueueItem(draggingIndex.value, nextIndex)
draggingIndex.value = nextIndex
}
const stopDrag = async () => {
clearPressTimer()
window.removeEventListener('pointermove', handleDragMove)
document.body.classList.remove('queue-dragging')
draggingIndex.value = null
await nextTick()
window.setTimeout(() => {
suppressClick.value = false
}, 0)
}
const playQueueItem = (index: number) => {
if (suppressClick.value) return
playerStore.playQueueIndex(index)
}
const removeQueueItem = (index: number) => {
playerStore.removeFromQueue(index)
}
onBeforeUnmount(() => {
clearPressTimer()
window.removeEventListener('pointermove', handleDragMove)
document.body.classList.remove('queue-dragging')
})
</script>
<style scoped>
.queue-list {
min-height: 0;
overflow-y: auto;
padding: 2px;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--album-color, var(--color-accent)) 24%, transparent) transparent;
}
.queue-list::-webkit-scrollbar {
width: 6px;
}
.queue-list::-webkit-scrollbar-track {
background: transparent;
}
.queue-list::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 24%, transparent);
border-radius: 999px;
}
.queue-row {
width: 100%;
min-height: 56px;
display: grid;
grid-template-columns: 28px 42px minmax(0, 1fr) auto 30px;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 14px;
color: var(--color-text-secondary);
text-align: left;
cursor: grab;
user-select: none;
touch-action: none;
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
}
.queue-row:hover,
.queue-row.active {
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 11%, transparent);
color: var(--color-text-primary);
}
.queue-row.dragging {
opacity: 0.58;
cursor: grabbing;
}
.queue-index {
width: 28px;
display: grid;
place-items: center;
font-size: 12px;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.queue-cover {
width: 42px;
height: 42px;
border-radius: 12px;
object-fit: cover;
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 11%, var(--color-bg-secondary));
overflow: hidden;
}
.queue-cover-placeholder {
display: grid;
place-items: center;
color: var(--color-text-muted);
}
.queue-info {
min-width: 0;
}
.queue-name,
.queue-artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.queue-name {
font-size: 13px;
font-weight: 700;
color: var(--color-text-primary);
}
.queue-artist {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-muted);
}
.queue-duration {
font-size: 12px;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.queue-remove {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--color-text-muted);
opacity: 0;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
}
.queue-row:hover .queue-remove,
.queue-row.active .queue-remove {
opacity: 1;
}
.queue-remove:hover {
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 12%, transparent);
color: var(--color-text-primary);
}
.playing-indicator {
height: 14px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
}
.playing-indicator .bar {
width: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 70%, var(--color-accent));
animation: queueBars 0.82s ease-in-out infinite;
}
.playing-indicator .bar:nth-child(1) {
height: 8px;
animation-delay: 0s;
}
.playing-indicator .bar:nth-child(2) {
height: 14px;
animation-delay: 0.14s;
}
.playing-indicator .bar:nth-child(3) {
height: 10px;
animation-delay: 0.28s;
}
.fullscreen {
--color-text-primary: rgba(255, 255, 255, 0.95);
--color-text-secondary: rgba(255, 255, 255, 0.72);
--color-text-muted: rgba(255, 255, 255, 0.45);
--color-bg-secondary: rgba(255, 255, 255, 0.08);
}
@keyframes queueBars {
0%, 100% { transform: scaleY(0.55); }
50% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.playing-indicator .bar {
animation: none;
}
}
:global(.queue-dragging) {
user-select: none;
cursor: grabbing;
}
</style>

View File

@@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted } from 'vue'; import { ref, onUnmounted } from 'vue';
const props = defineProps<{ defineProps<{
className?: string; className?: string;
}>(); }>();

View File

@@ -44,19 +44,8 @@ body {
display: flex; display: flex;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background-color: var(--color-bg-primary); background: var(--color-bg-primary);
overflow: hidden; /* 确保整个应用不会出现双重滚动条 */ overflow: hidden;
}
/* Dynamic Spacing for PlayerBar */
.layout-sidebar,
.content-area {
transition: padding-bottom 0.3s ease;
}
.main-layout.has-player .layout-sidebar,
.main-layout.has-player .content-area {
padding-bottom: 80px; /* PlayerBar Height */
} }
.content-area { .content-area {
@@ -66,14 +55,25 @@ body {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
min-width: 0; min-width: 0;
background:
var(--color-atmosphere-gradient) top / 100% 240px no-repeat,
var(--color-bg-primary);
} }
.page-content { .page-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0; padding: 0 0 20px;
background-color: var(--color-bg-primary); background: transparent;
position: relative; position: relative;
box-sizing: border-box;
scroll-padding-bottom: 20px;
transition: padding-bottom 0.24s ease, scroll-padding-bottom 0.24s ease;
}
.main-layout.has-player .page-content {
padding-bottom: 128px;
scroll-padding-bottom: 128px;
} }
/* 滚动条样式优化 */ /* 滚动条样式优化 */

View File

@@ -30,6 +30,46 @@ const router = createRouter({
name: 'Recent', name: 'Recent',
component: () => import('./views/Playlist.vue') component: () => import('./views/Playlist.vue')
}, },
{
path: '/together',
name: 'ListenTogether',
component: () => import('./views/ListenTogether.vue')
},
{
path: '/listen-stats',
name: 'ListenStats',
component: () => import('./views/ListenStats.vue')
},
{
path: '/listen-rank',
name: 'ListenRank',
component: () => import('./views/ListenRank.vue')
},
{
path: '/playlist-square',
name: 'PlaylistSquare',
component: () => import('./views/PlaylistSquare.vue')
},
{
path: '/user/:id',
name: 'UserProfile',
component: () => import('./views/UserProfile.vue')
},
{
path: '/user/:id/liked',
name: 'UserLikedPlaylist',
component: () => import('./views/Playlist.vue')
},
{
path: '/playlist/:scope/:id',
name: 'PlaylistDetail',
component: () => import('./views/Playlist.vue')
},
{
path: '/plugin/:pluginId/:kind/:id',
name: 'PluginCollection',
component: () => import('./views/Playlist.vue')
},
{ {
path: '/search', path: '/search',
name: 'Search', name: 'Search',

View File

@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
export interface UserInfo {
id: string
username: string
avatar?: string | null
nickname?: string | null
gender?: string | null
region?: string | null
intro?: string | null
birthday?: string | null
}
export interface AuthState {
accessToken: string
refreshToken: string
exp: number
userInfo: UserInfo | null
}
const emptyState: AuthState = {
accessToken: '',
refreshToken: '',
exp: 0,
userInfo: null,
}
let removeAuthListener: (() => void) | undefined
export const useAuthStore = defineStore('auth', () => {
const state = ref<AuthState>({ ...emptyState })
const loading = ref(false)
const isLoggedIn = computed(() => Boolean(state.value.accessToken && state.value.userInfo?.id))
const displayName = computed(() => state.value.userInfo?.nickname || state.value.userInfo?.username || '未登录')
const avatar = computed(() => state.value.userInfo?.avatar || '')
const init = async () => {
state.value = await window.electronAPI.auth.getState()
if (!removeAuthListener) {
removeAuthListener = window.electronAPI.auth.onChanged((payload) => {
state.value = payload.state || { ...emptyState }
if (payload.status === 'success') ElMessage.success('登录成功')
if (payload.status === 'error') ElMessage.error(payload.message || '登录失败')
})
}
if (state.value.accessToken) {
try {
state.value = await window.electronAPI.auth.refresh()
} catch (err) {
console.warn('[Auth] refresh failed:', err)
}
}
}
const login = async (forcePrompt = false) => {
loading.value = true
try {
await window.electronAPI.auth.login(forcePrompt)
} finally {
loading.value = false
}
}
const logout = async () => {
state.value = await window.electronAPI.auth.logout()
ElMessage.success('已退出登录')
}
const applyUserInfo = (userInfo: UserInfo) => {
state.value = {
...state.value,
userInfo,
}
}
return {
state,
loading,
isLoggedIn,
displayName,
avatar,
init,
login,
logout,
applyUserInfo,
}
})

View File

@@ -0,0 +1,296 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { Song } from '../types/song'
import { calibrateTime, calibratedNow } from './timeCalibrator'
type RoomMode = 'dual' | 'multi'
type ServerAction =
| 'ROOM_CREATED'
| 'SYNC_PROPERTIES'
| 'PING'
| 'UPDATE'
| 'SUCCESS'
| 'ROOM_CLOSED'
| 'ERROR'
interface ServerMessage {
action: ServerAction
data?: any
}
let socket: WebSocket | null = null
export const useListenTogetherStore = defineStore('listenTogether', () => {
const connecting = ref(false)
const connected = ref(false)
const roomId = ref('')
const mode = ref<RoomMode>('multi')
const permissionLevel = ref(0)
const userList = ref<string[]>([])
const allPermissions = ref<Record<string, number>>({})
const listVersion = ref(0)
const lastError = ref('')
const isApplyingRemote = ref(false)
const canControl = computed(() => connected.value && permissionLevel.value >= 1)
const isHost = computed(() => connected.value && permissionLevel.value >= 2)
const resetRoomState = () => {
connected.value = false
connecting.value = false
roomId.value = ''
permissionLevel.value = 0
userList.value = []
allPermissions.value = {}
listVersion.value = 0
}
const sendRaw = (action: string, data: Record<string, any> = {}) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return false
socket.send(JSON.stringify({ action, data }))
return true
}
const sendAction = (action: string, data: Record<string, any> = {}) => {
if (!connected.value && action !== 'PONG') return false
return sendRaw(action, data)
}
const getPlayer = async () => {
const mod = await import('./player')
return mod.usePlayerStore()
}
const playbackPayload = async () => {
const player = await getPlayer()
return {
currentMs: Math.max(0, Math.floor(player.currentTime || 0)),
currentIndex: Math.max(0, player.currentIndex),
timestamp: calibratedNow(),
}
}
const applyRemoteState = async (data: any) => {
const player = await getPlayer()
isApplyingRemote.value = true
try {
await player.applyTogetherState(data)
} finally {
window.setTimeout(() => {
isApplyingRemote.value = false
}, 120)
}
}
const sendCurrentSnapshot = async () => {
if (!canControl.value) return
const player = await getPlayer()
if (player.playlist.length > 0) {
sendAction('SET', {
baseListVersion: listVersion.value,
list: player.playlist,
})
}
const payload = await playbackPayload()
sendAction('SEEK', payload)
sendAction(player.isPlaying ? 'PLAY' : 'PAUSE', payload)
}
const handleMessage = async (message: ServerMessage) => {
const data = message.data || {}
switch (message.action) {
case 'ROOM_CREATED':
roomId.value = data.room_id || ''
connected.value = true
connecting.value = false
permissionLevel.value = 2
lastError.value = ''
ElMessage.success('一起听房间已创建')
await sendCurrentSnapshot()
break
case 'SYNC_PROPERTIES':
permissionLevel.value = Number(data.permission_level ?? permissionLevel.value)
mode.value = data.mode === 'dual' ? 'dual' : 'multi'
userList.value = Array.isArray(data.user_list) ? data.user_list : []
allPermissions.value = data.all_permissions || {}
break
case 'PING':
sendRaw('PONG')
if (isHost.value) {
sendAction('SYNC', await playbackPayload())
} else {
await applyRemoteState(data)
}
break
case 'UPDATE':
if (typeof data.listVersion === 'number') listVersion.value = data.listVersion
await applyRemoteState(data)
break
case 'SUCCESS':
if (typeof data.listVersion === 'number') listVersion.value = data.listVersion
break
case 'ROOM_CLOSED':
ElMessage.info('一起听房间已关闭')
disconnect(false)
break
case 'ERROR':
lastError.value = data.msg || '一起听同步失败'
ElMessage.warning(lastError.value)
if (data.code === '409') sendAction('GET')
break
}
}
const connect = async (params: Record<string, string>) => {
disconnect(false)
connecting.value = true
lastError.value = ''
calibrateTime().catch(() => {})
try {
const url = await window.electronAPI.listenTogether.getWsUrl(params)
socket = new WebSocket(url)
socket.onopen = () => {
connected.value = true
connecting.value = false
}
socket.onmessage = (event) => {
try {
handleMessage(JSON.parse(event.data)).catch(console.error)
} catch (error) {
console.warn('[ListenTogether] Invalid message:', error)
}
}
socket.onerror = () => {
lastError.value = '一起听连接失败'
connecting.value = false
}
socket.onclose = () => {
socket = null
resetRoomState()
}
} catch (error: any) {
connecting.value = false
lastError.value = error?.message || '一起听连接失败'
ElMessage.error(lastError.value)
}
}
const createRoom = async (nextMode: RoomMode) => {
mode.value = nextMode
await connect({ mode: nextMode })
}
const joinRoom = async (targetRoomId: string) => {
const normalized = targetRoomId.trim()
if (!normalized) return
await connect({ room_id: normalized })
}
const disconnect = (notifyServer = true) => {
if (notifyServer && socket?.readyState === WebSocket.OPEN && isHost.value) {
sendRaw('CLOSE_ROOM')
}
if (socket) {
socket.close(1000)
socket = null
}
resetRoomState()
}
const sendPlayback = async (playing: boolean) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction(playing ? 'PLAY' : 'PAUSE', await playbackPayload())
}
const sendSeek = async (currentMs: number, currentIndex: number) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction('SEEK', {
currentMs: Math.max(0, Math.floor(currentMs || 0)),
currentIndex: Math.max(0, currentIndex),
timestamp: calibratedNow(),
})
}
const sendSetList = (list: Song[]) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction('SET', {
baseListVersion: listVersion.value,
list,
})
}
const sendAddSong = (song: Song) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction('ADD', {
baseListVersion: listVersion.value,
song,
})
}
const sendInsertSong = (song: Song, index: number) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction('INSERT', {
baseListVersion: listVersion.value,
index,
song,
})
}
const sendRemoveSong = (song: Song, index: number, currentIndex: number) => {
if (!canControl.value || isApplyingRemote.value) return
sendAction('REMOVE', {
baseListVersion: listVersion.value,
song,
index,
currentIndex,
})
}
const changePermission = (targetUserId: string, level: 0 | 1) => {
if (!isHost.value) return
sendAction('CHANGE_PERMISSION', {
target_user_id: targetUserId,
level,
})
}
return {
connecting,
connected,
roomId,
mode,
permissionLevel,
userList,
allPermissions,
listVersion,
lastError,
isApplyingRemote,
canControl,
isHost,
createRoom,
joinRoom,
disconnect,
sendCurrentSnapshot,
sendPlayback,
sendSeek,
sendSetList,
sendAddSong,
sendInsertSong,
sendRemoveSong,
changePermission,
}
})

View File

@@ -3,6 +3,8 @@ import { ref, shallowRef, watch } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import type { Song } from '../types/song'; import type { Song } from '../types/song';
import { parseLyric } from '../utils/lyricUtil' import { parseLyric } from '../utils/lyricUtil'
import { useListenTogetherStore } from './listenTogether';
import { calibratedNow } from './timeCalibrator';
export enum PlayMode { export enum PlayMode {
List = 'list', List = 'list',
Single = 'single', Single = 'single',
@@ -55,11 +57,20 @@ export const usePlayerStore = defineStore('player', () => {
const playMode = ref<PlayMode>(PlayMode.List); const playMode = ref<PlayMode>(PlayMode.List);
const savedAddMode = localStorage.getItem('qz-player-add-mode'); const savedAddMode = localStorage.getItem('qz-player-add-mode');
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace'); const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
const openPlayerOnSongClick = ref(false);
// Error Handling // Error Handling
const playErrorCount = ref(0); const playErrorCount = ref(0);
const MAX_RETRY_COUNT = 3; const currentSongRetryCount = ref(0);
const hasRetriedWithFreshUrl = ref(false); const MAX_SONG_RETRY_COUNT = 1;
const MAX_CONSECUTIVE_SKIP_COUNT = 3;
let handlingPlayError = false;
const HEARTBEAT_INTERVAL_MS = 300_000;
const heartbeatDuration = ref(0);
let lastHeartbeatTick = Date.now();
let heartbeatSending = false;
let lastTaskbarProgress = -1;
let lastTaskbarMode: 'normal' | 'paused' = 'normal';
// Lyrics State // Lyrics State
const lyrics = shallowRef<{ lines: any[] }>({ lines: [] }); const lyrics = shallowRef<{ lines: any[] }>({ lines: [] });
@@ -93,14 +104,99 @@ export const usePlayerStore = defineStore('player', () => {
} }
}; };
const listenTogether = () => {
try {
return useListenTogetherStore();
} catch {
return null;
}
};
const syncTaskbarProgress = () => {
if (!window.electronAPI?.setTaskbarProgress) return;
if (!currentSong.value || duration.value <= 0) {
if (lastTaskbarProgress !== -1) {
lastTaskbarProgress = -1;
window.electronAPI.setTaskbarProgress(-1).catch(console.warn);
}
return;
}
const progress = Math.max(0, Math.min(1, currentTime.value / duration.value));
const mode: 'normal' | 'paused' = isPlaying.value ? 'normal' : 'paused';
if (Math.abs(progress - lastTaskbarProgress) < 0.002 && mode === lastTaskbarMode) return;
lastTaskbarProgress = progress;
lastTaskbarMode = mode;
window.electronAPI.setTaskbarProgress(progress, mode).catch(console.warn);
};
const notifyTogetherSetList = () => {
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendSetList([...playlist.value]);
}
};
const notifyTogetherSeek = () => {
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendSeek(currentTime.value, currentIndex.value);
}
};
const notifyTogetherPlayback = (playing = isPlaying.value) => {
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendPlayback(playing);
}
};
const tickHeartbeat = async () => {
const now = Date.now();
const delta = now - lastHeartbeatTick;
lastHeartbeatTick = now;
if (!isPlaying.value || !currentSong.value) return;
heartbeatDuration.value += delta;
if (heartbeatDuration.value < HEARTBEAT_INTERVAL_MS || heartbeatSending) return;
heartbeatSending = true;
const durationToSend = HEARTBEAT_INTERVAL_MS;
heartbeatDuration.value = Math.max(0, heartbeatDuration.value - durationToSend);
try {
await window.electronAPI?.heartbeat?.sendPc(durationToSend, calibratedNow());
} catch (error) {
console.warn('[Heartbeat] PC heartbeat failed:', error);
} finally {
heartbeatSending = false;
}
};
if (typeof window !== 'undefined') {
window.electronAPI?.settings?.getAll?.()
.then((settings) => {
openPlayerOnSongClick.value = Boolean(settings.openPlayerOnSongClick);
})
.catch((error) => console.warn('[Player] Failed to load click preference:', error));
window.addEventListener('qz-open-player-on-song-click-changed', (event) => {
openPlayerOnSongClick.value = Boolean((event as CustomEvent<boolean>).detail);
});
window.setInterval(() => {
tickHeartbeat().catch(console.error);
}, 1000);
}
// --- Actions --- // --- Actions ---
const setPlaylist = async (list: any[], startIndex = 0) => { const setPlaylist = async (list: any[], startIndex = 0) => {
// Legacy support or direct set // Legacy support or direct set
playlist.value = list; playlist.value = list;
currentIndex.value = startIndex; currentIndex.value = startIndex;
notifyTogetherSetList();
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) { if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
await playSong(list[startIndex]); await playSong(list[startIndex], true, startIndex);
} }
}; };
@@ -125,17 +221,26 @@ export const usePlayerStore = defineStore('player', () => {
playlist.value.push(song); playlist.value.push(song);
const newIndex = playlist.value.length - 1; const newIndex = playlist.value.length - 1;
currentIndex.value = newIndex; currentIndex.value = newIndex;
await playSong(song); notifyTogetherSetList();
await playSong(song, true, newIndex);
}
if (openPlayerOnSongClick.value && !isPlayerFullScreen.value) {
toggleFullScreen();
} }
}; };
const playSong = async (song: Song, autoPlay = true) => { const playSong = async (song: Song, autoPlay = true, queueIndex?: number) => {
if (!song) return; if (!song) return;
console.log(song); console.log(song);
currentSong.value = song; currentSong.value = song;
const foundIndex = playlist.value.findIndex(s => s.id === song.id); if (typeof queueIndex === 'number') {
if (foundIndex !== -1) { currentIndex.value = queueIndex;
currentIndex.value = foundIndex; } else {
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
if (foundIndex !== -1) {
currentIndex.value = foundIndex;
}
} }
await activateDummyAudio(); await activateDummyAudio();
@@ -153,8 +258,6 @@ export const usePlayerStore = defineStore('player', () => {
if (playUrl) { if (playUrl) {
console.log('Playing:', song.name, 'AutoPlay:', autoPlay); console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
// Reset retry flag for new playback attempt
hasRetriedWithFreshUrl.value = false;
try { try {
await window.electronAPI.qzplayer.load(playUrl); await window.electronAPI.qzplayer.load(playUrl);
if (autoPlay) { if (autoPlay) {
@@ -170,20 +273,31 @@ export const usePlayerStore = defineStore('player', () => {
syncDummyAudioState(false); syncDummyAudioState(false);
} }
song.url = playUrl; song.url = playUrl;
currentSongRetryCount.value = 0;
playErrorCount.value = 0;
notifyTogetherSeek();
notifyTogetherPlayback(autoPlay);
} catch (e) { } catch (e) {
// IPC call failed (rare), handle sync error // IPC call failed (rare), handle sync error
console.error("IPC Play request failed:", e); console.error("IPC Play request failed:", e);
if (autoPlay) handlePlayError().then(); if (autoPlay) await handlePlayError();
} }
} else { } else {
console.warn("Song has no URL"); console.warn("Song has no URL");
if (autoPlay) handlePlayError().then(); if (autoPlay) await handlePlayError();
} }
}; };
const fetchLyrics = async (song: Song) => { const fetchLyrics = async (song: Song) => {
lyrics.value = { lines: [] }; // Reset lyrics.value = { lines: [] }; // Reset
if (!song || !song.id) return; if (!song || !song.id) return;
if (song.type === 'Local' || song.source === 'local') {
const localLyric = typeof song.lyric === 'string' ? song.lyric.trim() : '';
if (localLyric) {
lyrics.value = { lines: parseLyric(localLyric) };
}
return;
}
try { try {
//Check if plugin API exists //Check if plugin API exists
if (window.electronAPI?.plugin?.getLyric) { if (window.electronAPI?.plugin?.getLyric) {
@@ -231,7 +345,7 @@ export const usePlayerStore = defineStore('player', () => {
nextIndex = (currentIndex.value + 1) % playlist.value.length; nextIndex = (currentIndex.value + 1) % playlist.value.length;
} }
currentIndex.value = nextIndex; currentIndex.value = nextIndex;
await playSong(playlist.value[nextIndex]); await playSong(playlist.value[nextIndex], true, nextIndex);
}; };
const prev = async () => { const prev = async () => {
@@ -243,32 +357,188 @@ export const usePlayerStore = defineStore('player', () => {
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length; prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
} }
currentIndex.value = prevIndex; currentIndex.value = prevIndex;
await playSong(playlist.value[prevIndex]); await playSong(playlist.value[prevIndex], true, prevIndex);
}; };
const handlePlayError = async () => { const playQueueIndex = async (index: number) => {
// Proxy handles refreshing internally, so we rely on qzplayer error/retry for now. if (index < 0 || index >= playlist.value.length) return;
// Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work). currentIndex.value = index;
await playSong(playlist.value[index], true, index);
};
// Normal error handling const playNextInQueue = async (song: Song) => {
playErrorCount.value++; if (!song) return;
hasRetriedWithFreshUrl.value = false; if (playlist.value.length === 0) {
playlist.value = [song];
currentIndex.value = 0;
notifyTogetherSetList();
await playSong(song, true, 0);
return;
}
const insertIndex = currentIndex.value >= 0 ? currentIndex.value + 1 : playlist.value.length;
playlist.value.splice(insertIndex, 0, song);
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendInsertSong(song, insertIndex);
}
ElMessage.success('已设为下一首播放');
};
const appendToQueue = async (song: Song) => {
if (!song) return;
playlist.value.push(song);
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendAddSong(song);
}
if (!currentSong.value || currentIndex.value === -1) {
currentIndex.value = playlist.value.length - 1;
await playSong(song, true, currentIndex.value);
return;
}
ElMessage.success('已添加到播放队列');
};
const removeFromQueue = async (index: number) => {
if (index < 0 || index >= playlist.value.length) return;
const removedCurrent = index === currentIndex.value;
const removedSong = playlist.value[index];
playlist.value.splice(index, 1);
const together = listenTogether();
if (together?.canControl && !together.isApplyingRemote) {
together.sendRemoveSong(removedSong, index, currentIndex.value);
}
if (playlist.value.length === 0) { if (playlist.value.length === 0) {
currentIndex.value = -1;
currentSong.value = null;
isPlaying.value = false; isPlaying.value = false;
playErrorCount.value = 0; await window.electronAPI.qzplayer.pause();
syncDummyAudioState(false); syncDummyAudioState(false);
return; return;
} }
if (playErrorCount.value >= MAX_RETRY_COUNT) {
window.electronAPI.qzplayer.pause().then(); if (index < currentIndex.value) {
currentIndex.value -= 1;
} else if (removedCurrent) {
currentIndex.value = Math.min(index, playlist.value.length - 1);
await playSong(playlist.value[currentIndex.value], true, currentIndex.value);
}
};
const moveQueueItem = (fromIndex: number, toIndex: number) => {
if (
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= playlist.value.length ||
toIndex >= playlist.value.length
) {
return;
}
const [song] = playlist.value.splice(fromIndex, 1);
playlist.value.splice(toIndex, 0, song);
if (currentIndex.value === fromIndex) {
currentIndex.value = toIndex;
} else if (fromIndex < currentIndex.value && toIndex >= currentIndex.value) {
currentIndex.value -= 1;
} else if (fromIndex > currentIndex.value && toIndex <= currentIndex.value) {
currentIndex.value += 1;
}
notifyTogetherSetList();
};
const applyTogetherState = async (data: any) => {
const nextList = Array.isArray(data.list) ? data.list as Song[] : playlist.value;
const nextIndex = Math.max(
0,
Math.min(Number(data.currentIndex ?? currentIndex.value) || 0, Math.max(0, nextList.length - 1))
);
const nextMs = Math.max(0, Number(data.currentMs ?? currentTime.value) || 0);
const shouldPlay = Boolean(data.playing);
if (Array.isArray(data.list)) {
playlist.value = nextList;
}
if (nextList.length === 0) {
playlist.value = [];
currentIndex.value = -1;
currentSong.value = null;
isPlaying.value = false; isPlaying.value = false;
ElMessage.error('连续多次播放失败,已停止播放'); await window.electronAPI.qzplayer.pause();
playErrorCount.value = 0;
syncDummyAudioState(false); syncDummyAudioState(false);
} else { return;
ElMessage.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`); }
next(false)
const nextSong = nextList[nextIndex];
const needsLoad =
!currentSong.value ||
currentSong.value.id !== nextSong.id ||
currentSong.value.source !== nextSong.source ||
currentIndex.value !== nextIndex;
currentIndex.value = nextIndex;
if (needsLoad) {
await playSong(nextSong, shouldPlay, nextIndex);
} else if (shouldPlay !== isPlaying.value) {
if (shouldPlay) {
await window.electronAPI.qzplayer.play();
isPlaying.value = true;
syncDummyAudioState(true);
} else {
await window.electronAPI.qzplayer.pause();
isPlaying.value = false;
syncDummyAudioState(false);
}
}
if (Math.abs((currentTime.value || 0) - nextMs) > 1200) {
await window.electronAPI.qzplayer.seek(nextMs);
currentTime.value = nextMs;
}
};
const handlePlayError = async () => {
if (handlingPlayError) return;
handlingPlayError = true;
try {
if (playlist.value.length === 0) {
isPlaying.value = false;
playErrorCount.value = 0;
currentSongRetryCount.value = 0;
syncDummyAudioState(false);
return;
}
const failedSong = currentSong.value;
if (failedSong && currentSongRetryCount.value < MAX_SONG_RETRY_COUNT) {
currentSongRetryCount.value++;
ElMessage.warning('\u64ad\u653e\u5931\u8d25\uff0c\u6b63\u5728\u91cd\u8bd5\u5f53\u524d\u6b4c\u66f2');
handlingPlayError = false;
await playSong(failedSong, true, currentIndex.value);
return;
}
currentSongRetryCount.value = 0;
playErrorCount.value++;
if (playErrorCount.value >= MAX_CONSECUTIVE_SKIP_COUNT) {
await window.electronAPI.qzplayer.pause();
isPlaying.value = false;
ElMessage.error('\u8fde\u7eed 3 \u9996\u6b4c\u66f2\u64ad\u653e\u5931\u8d25\uff0c\u5df2\u6682\u505c\u64ad\u653e');
playErrorCount.value = 0;
syncDummyAudioState(false);
} else {
ElMessage.warning(`\u5f53\u524d\u6b4c\u66f2\u4ecd\u65e0\u6cd5\u64ad\u653e\uff0c\u5df2\u8df3\u8fc7 (${playErrorCount.value}/${MAX_CONSECUTIVE_SKIP_COUNT})`);
handlingPlayError = false;
await next(true);
}
} finally {
handlingPlayError = false;
} }
}; };
@@ -280,6 +550,7 @@ export const usePlayerStore = defineStore('player', () => {
const isPaused = data.data; const isPaused = data.data;
isPlaying.value = !isPaused; isPlaying.value = !isPaused;
syncDummyAudioState(!isPaused); syncDummyAudioState(!isPaused);
notifyTogetherPlayback(!isPaused);
} }
if (data.name === 'time-pos') currentTime.value = data.data; //毫秒级 if (data.name === 'time-pos') currentTime.value = data.data; //毫秒级
if (data.name === 'duration') duration.value = data.data; //毫秒级 if (data.name === 'duration') duration.value = data.data; //毫秒级
@@ -309,6 +580,8 @@ export const usePlayerStore = defineStore('player', () => {
const seek = async (time: number) => { const seek = async (time: number) => {
await window.electronAPI.qzplayer.seek(time); await window.electronAPI.qzplayer.seek(time);
currentTime.value = time;
notifyTogetherSeek();
}; };
const toggleMode = () => { const toggleMode = () => {
@@ -318,7 +591,21 @@ export const usePlayerStore = defineStore('player', () => {
}; };
const toggleFullScreen = () => { const toggleFullScreen = () => {
isPlayerFullScreen.value = !isPlayerFullScreen.value; const startViewTransition = (document as any).startViewTransition;
const toggle = () => {
isPlayerFullScreen.value = !isPlayerFullScreen.value;
};
if (typeof startViewTransition === 'function') {
try {
startViewTransition(toggle);
} catch (error) {
console.warn('View transition failed, falling back to direct player toggle:', error);
toggle();
}
} else {
toggle();
}
}; };
// Persistence Listeners // Persistence Listeners
@@ -331,13 +618,15 @@ export const usePlayerStore = defineStore('player', () => {
localStorage.setItem('qz-player-add-mode', newMode); localStorage.setItem('qz-player-add-mode', newMode);
}); });
watch([currentTime, duration, isPlaying, currentSong], syncTaskbarProgress, { immediate: true });
// Restore initial state (without playing) // Restore initial state (without playing)
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) { if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
const restoredSong = playlist.value[currentIndex.value]; const restoredSong = playlist.value[currentIndex.value];
if (restoredSong) { if (restoredSong) {
// Use playSong with autoPlay=false to load the song into the engine // Use playSong with autoPlay=false to load the song into the engine
setTimeout(() => { setTimeout(() => {
playSong(restoredSong, true); playSong(restoredSong, true, currentIndex.value);
fetchLyrics(restoredSong); fetchLyrics(restoredSong);
}, 0); }, 0);
} }
@@ -350,12 +639,19 @@ export const usePlayerStore = defineStore('player', () => {
duration, duration,
currentTime, currentTime,
playlist, playlist,
currentIndex,
playMode, playMode,
loudness, loudness,
spectrum, spectrum,
isPlayerFullScreen, isPlayerFullScreen,
setPlaylist, setPlaylist,
playSong, playSong,
playQueueIndex,
playNextInQueue,
appendToQueue,
removeFromQueue,
moveQueueItem,
applyTogetherState,
next, next,
prev, prev,
togglePlay, togglePlay,

View File

@@ -0,0 +1,163 @@
import { defineStore } from 'pinia'
import { computed, ref, toRaw } from 'vue'
import { ElMessage } from 'element-plus'
import type { Song } from '../types/song'
export type PlaylistScope = 'local' | 'cloud' | 'plugin'
export type ManagedPlaylistScope = 'local' | 'cloud'
export interface PlaylistInfo {
id: string
name: string
desc: string
img: string
cover_mode?: 'auto' | 'custom' | string
author?: string
play_count?: string
visit_count?: number
is_public?: boolean
}
export interface AppPlaylist {
id: string
scope: PlaylistScope
source: string
kind?: 'playlist' | 'album'
info: PlaylistInfo
list: Song[]
total: number
}
const toPlainSong = (song: Song): Song => {
const raw = toRaw(song) as Song & Record<string, any>
return {
id: String(raw.id ?? ''),
hash: raw.hash ?? null,
picUrl: String(raw.picUrl ?? ''),
url: String(raw.url ?? ''),
name: String(raw.name ?? ''),
artist: String(raw.artist ?? ''),
duration: String(raw.duration ?? ''),
source: String(raw.source ?? ''),
lyric: typeof raw.lyric === 'string' ? raw.lyric : undefined,
quality: raw.quality,
albumId: raw.albumId ?? null,
albumName: raw.albumName ?? null,
artistIds: Array.isArray(raw.artistIds) ? raw.artistIds.map(String) : null,
type: raw.type,
types: raw.types && typeof raw.types === 'object' ? { ...raw.types } : undefined,
}
}
export const usePlaylistsStore = defineStore('playlists', () => {
const local = ref<AppPlaylist[]>([])
const cloud = ref<AppPlaylist[]>([])
const loading = ref(false)
const all = computed(() => [...local.value, ...cloud.value])
const refresh = async () => {
loading.value = true
try {
const result = await window.electronAPI.playlist.list()
local.value = result.local || []
cloud.value = result.cloud || []
} catch (err: any) {
console.error('[Playlist] refresh failed:', err)
ElMessage.error(err?.message || '歌单加载失败')
} finally {
loading.value = false
}
}
const publicList = async (search = '', sort = 'visit', page = 1, limit = 50) => {
return await window.electronAPI.playlist.publicList(search, sort, page, limit)
}
const get = async (scope: PlaylistScope, id: string) => {
return await window.electronAPI.playlist.get(scope as ManagedPlaylistScope, id) as AppPlaylist
}
const create = async (scope: ManagedPlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {
const playlist = await window.electronAPI.playlist.create(scope, data) as AppPlaylist
await refresh()
ElMessage.success(scope === 'local' ? '本地歌单已创建' : '云端歌单已创建')
return playlist
}
const update = async (scope: ManagedPlaylistScope, id: string, info: Partial<PlaylistInfo>) => {
const playlist = await window.electronAPI.playlist.update(scope, id, info) as AppPlaylist
await refresh()
ElMessage.success('歌单已更新')
return playlist
}
const remove = async (scope: ManagedPlaylistScope, id: string) => {
const result = await window.electronAPI.playlist.delete(scope, id)
await refresh()
if (result.success) ElMessage.success('歌单已删除')
return result
}
const addSong = async (scope: ManagedPlaylistScope, id: string, song: Song, index = -1) => {
const playlist = await window.electronAPI.playlist.addSong(scope, id, toPlainSong(song), index) as AppPlaylist
await refresh()
ElMessage.success('已添加到歌单')
return playlist
}
const removeSong = async (scope: ManagedPlaylistScope, id: string, index: number) => {
const playlist = await window.electronAPI.playlist.removeSong(scope, id, index) as AppPlaylist
await refresh()
ElMessage.success('已从歌单移除')
return playlist
}
const exportPlaylist = async (scope: ManagedPlaylistScope, id: string) => {
const result = await window.electronAPI.playlist.export(scope, id)
if (result?.success) ElMessage.success('歌单已导出')
return result
}
const importPlaylist = async () => {
const result = await window.electronAPI.playlist.import()
if (result?.success) {
await refresh()
ElMessage.success('歌单已导入')
}
return result
}
const convertScope = async (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => {
const playlist = await window.electronAPI.playlist.convertScope(scope, id, targetScope) as AppPlaylist
await refresh()
ElMessage.success(targetScope === 'cloud' ? '已转换为云端歌单' : '已转换为本地歌单')
return playlist
}
const copyToLocal = async (scope: ManagedPlaylistScope, id: string) => {
const playlist = await window.electronAPI.playlist.copyToLocal(scope, id) as AppPlaylist
await refresh()
ElMessage.success('已另存为本地歌单')
return playlist
}
return {
local,
cloud,
all,
loading,
refresh,
publicList,
get,
create,
update,
remove,
addSong,
removeSong,
exportPlaylist,
importPlaylist,
convertScope,
copyToLocal,
}
})

View File

@@ -0,0 +1,43 @@
const API_BASE_URL = 'https://api.qz.shiqianjiang.cn/app'
const CALIBRATE_ROUNDS = 3
let offsetMs = 0
async function singleCalibrate(): Promise<{ offset: number; rtt: number } | null> {
const localBefore = Date.now()
try {
const resp = await fetch(`${API_BASE_URL}/time`, { cache: 'no-store' })
const localAfter = Date.now()
if (!resp.ok) return null
const data = await resp.json() as { timestamp: number }
const rtt = localAfter - localBefore
const offset = data.timestamp + Math.floor(rtt / 2) - localAfter
return { offset, rtt }
} catch {
return null
}
}
export async function calibrateTime(): Promise<void> {
let bestOffset = 0
let minRtt = Infinity
for (let i = 0; i < CALIBRATE_ROUNDS; i++) {
const result = await singleCalibrate()
if (!result) continue
if (result.rtt < minRtt) {
minRtt = result.rtt
bestOffset = result.offset
}
console.debug(`[TimeCalibrator] Round ${i}: rtt=${result.rtt}ms, offset=${result.offset}ms`)
}
if (minRtt < Infinity) {
offsetMs = bestOffset
console.log(`[TimeCalibrator] Calibrated: offset=${offsetMs}ms (best rtt=${minRtt}ms)`)
}
}
export function calibratedNow(): number {
return Date.now() + offsetMs
}

View File

@@ -53,7 +53,7 @@ input[type='radio'] {
input[type='radio']:checked { input[type='radio']:checked {
border-color: var(--color-accent); border-color: var(--color-accent);
background-color: var(--color-accent); background: var(--color-accent-gradient);
} }
input[type='radio']:checked::after { input[type='radio']:checked::after {
@@ -87,3 +87,57 @@ input[type='radio']:checked::after {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted); background: var(--color-text-muted);
} }
.el-overlay {
background-color: rgba(12, 12, 12, 0.18) !important;
backdrop-filter: blur(14px) saturate(1.08);
}
.el-message-box,
.el-dialog,
.el-popover.el-popper {
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent) !important;
border-radius: 22px !important;
background: color-mix(in srgb, var(--color-bg-primary) 84%, transparent) !important;
box-shadow: var(--shadow-elevated) !important;
color: var(--color-text-primary) !important;
backdrop-filter: blur(24px) saturate(1.12);
}
.el-message-box {
padding: 8px !important;
}
.el-message-box__title,
.el-dialog__title {
color: var(--color-text-primary) !important;
font-size: 16px !important;
font-weight: 700 !important;
}
.el-message-box__message,
.el-dialog__body {
color: var(--color-text-secondary) !important;
}
.el-message {
border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent) !important;
border-radius: 999px !important;
background: color-mix(in srgb, var(--color-bg-primary) 88%, transparent) !important;
box-shadow: var(--shadow-lg) !important;
backdrop-filter: blur(18px) saturate(1.08);
}
.el-message .el-message__content {
color: var(--color-text-primary) !important;
font-weight: 600;
}
.el-button {
border-radius: 999px !important;
}
.el-button--primary {
border-color: transparent !important;
background: var(--color-accent-gradient) !important;
}

View File

@@ -3,7 +3,16 @@
--theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; --theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
/* Dynamic accent color (set via JS) */ /* Dynamic accent color (set via JS) */
--color-accent: #ec4141; --color-accent: #8289d3;
--color-accent-gradient: #8289d3;
--color-atmosphere-gradient:
linear-gradient(180deg,
rgba(176, 186, 235, 0.36) 0%,
rgba(177, 191, 233, 0.31) 18%,
rgba(179, 201, 223, 0.25) 38%,
rgba(193, 192, 211, 0.18) 58%,
rgba(223, 172, 185, 0.11) 78%,
transparent 100%);
--color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, white); --color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, white);
--color-accent-soft: color-mix(in srgb, var(--color-accent) 10%, transparent); --color-accent-soft: color-mix(in srgb, var(--color-accent) 10%, transparent);
@@ -24,8 +33,8 @@
--radius-2xl: 32px; --radius-2xl: 32px;
--radius-full: 9999px; --radius-full: 9999px;
--sidebar-width: 240px; --sidebar-width: 284px;
--topbar-height: 64px; --topbar-height: 72px;
/* Transitions */ /* Transitions */
--transition-fast: 0.15s ease; --transition-fast: 0.15s ease;

View File

@@ -3,6 +3,7 @@ export interface IElectronAPI {
maximizeWindow: () => void; maximizeWindow: () => void;
closeWindow: () => void; closeWindow: () => void;
isMaximized: () => Promise<boolean>; isMaximized: () => Promise<boolean>;
setTaskbarProgress: (progress: number, mode?: 'normal' | 'paused') => Promise<boolean>;
qzplayer: { qzplayer: {
load: (url: string) => Promise<void>; load: (url: string) => Promise<void>;
play: () => Promise<void>; play: () => Promise<void>;
@@ -17,11 +18,62 @@ export interface IElectronAPI {
call: (pluginId: string, method: string, args: any[]) => Promise<any>; call: (pluginId: string, method: string, args: any[]) => Promise<any>;
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>; search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
getLyric: (pluginId: string, id: string) => Promise<any>; getLyric: (pluginId: string, id: string) => Promise<any>;
getPlaylist: (pluginId: string, id: string, page?: number, limit?: number) => Promise<AppPlaylist>;
getAlbum: (pluginId: string, id: string, page?: number, limit?: number) => Promise<AppPlaylist>;
getAll: () => Promise<any[]>; getAll: () => Promise<any[]>;
uninstall: (pluginId: string) => Promise<boolean>; uninstall: (pluginId: string) => Promise<boolean>;
install: () => Promise<{ success: boolean; message: string }>; install: () => Promise<{ success: boolean; message: string }>;
onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void; onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void;
}; };
auth: {
getState: () => Promise<AuthState>;
getAccessToken: () => Promise<string>;
login: (forcePrompt?: boolean) => Promise<{ success: boolean; url: string }>;
qrCreate: () => Promise<QrLoginSession>;
qrPoll: (sessionId: string, pollToken: string) => Promise<QrLoginPollResult>;
qrCancel: (sessionId: string, pollToken: string) => Promise<{ status: string; message?: string }>;
refresh: () => Promise<AuthState>;
logout: () => Promise<AuthState>;
onChanged: (callback: (payload: { status: string; message?: string; state: AuthState }) => void) => () => void;
};
listenTogether: {
getWsUrl: (params: Record<string, string>) => Promise<string>;
};
heartbeat: {
sendPc: (duration: number, timestamp?: number) => Promise<any>;
};
stats: {
getListenTime: (detail?: number, userId?: string) => Promise<ListenTimeStat>;
getListenRange: (start: string, end: string, userId?: string) => Promise<ListenTimeRange>;
getListenRank: (period?: ListenRankPeriod, limit?: number) => Promise<ListenRankResponse>;
};
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>;
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 }>;
addSong: (scope: ManagedPlaylistScope, id: string, song: any, index?: number) => Promise<AppPlaylist>;
removeSong: (scope: ManagedPlaylistScope, id: string, index: number) => Promise<AppPlaylist>;
export: (scope: ManagedPlaylistScope, id: string) => Promise<{ success: boolean; canceled?: boolean; path?: string }>;
import: () => Promise<{ success: boolean; canceled?: boolean; playlist?: AppPlaylist }>;
convertScope: (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => Promise<AppPlaylist>;
copyToLocal: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
};
image: {
selectAndUpload: () => Promise<{ success: boolean; canceled?: boolean; url?: string; message?: string }>;
};
privacy: {
getLibrary: () => Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }>;
setLibrary: (payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }>;
};
user: {
getProfile: (userId: string) => Promise<UserInfo>;
getPlaylists: (userId: string) => Promise<PlaylistInfo[]>;
getFavSongs: (userId: string) => Promise<any[]>;
updateProfile: (payload: Partial<UserInfo>) => Promise<UserInfo>;
};
// Cache Control // Cache Control
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>; getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
setCachePersist: (persist: boolean) => Promise<void>; setCachePersist: (persist: boolean) => Promise<void>;
@@ -29,10 +81,18 @@ export interface IElectronAPI {
clearCache: () => Promise<void>; clearCache: () => Promise<void>;
changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>; changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>;
selectDirectory: () => Promise<string | null>; selectDirectory: () => Promise<string | null>;
selectDirectories: () => Promise<string[]>;
localMusic: {
getLibrary: () => Promise<LocalMusicLibrary>;
scan: (roots: string[]) => Promise<LocalMusicLibrary>;
setRoots: (roots: string[]) => Promise<LocalMusicLibrary>;
remove: (id: string) => Promise<LocalMusicLibrary>;
clearMissing: () => Promise<LocalMusicLibrary>;
};
// Settings // Settings
settings: { settings: {
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>; getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string; playlistPagingMode: 'infinite' | 'pagination'; openPlayerOnSongClick: boolean }>;
set: (settings: Partial<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>) => Promise<any>; set: (settings: Partial<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string; playlistPagingMode: 'infinite' | 'pagination'; openPlayerOnSongClick: boolean }>) => Promise<any>;
getTheme: () => Promise<'dark' | 'light'>; getTheme: () => Promise<'dark' | 'light'>;
setTheme: (theme: 'dark' | 'light') => Promise<void>; setTheme: (theme: 'dark' | 'light') => Promise<void>;
getAccentColor: () => Promise<string>; getAccentColor: () => Promise<string>;
@@ -45,3 +105,142 @@ declare global {
electronAPI: IElectronAPI electronAPI: IElectronAPI
} }
} }
export interface UserInfo {
id: string;
username: string;
avatar?: string | null;
nickname?: string | null;
gender?: string | null;
region?: string | null;
intro?: string | null;
birthday?: string | null;
subscribing?: boolean;
}
export interface AuthState {
accessToken: string;
refreshToken: string;
exp: number;
userInfo: UserInfo | null;
}
export interface QrLoginSession {
status: string;
session_id: string;
poll_token: string;
qr_payload: string;
qr_data_url: string;
expires_at: number;
expires_in: number;
message?: string;
}
export interface QrLoginPollResult {
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'error' | string;
message?: string;
device_name?: string;
expires_at?: number;
state?: AuthState;
}
export type PlaylistScope = 'local' | 'cloud' | 'plugin';
export type ManagedPlaylistScope = 'local' | 'cloud';
export interface PlaylistInfo {
id: string;
name: string;
desc: string;
img: string;
cover_mode?: 'auto' | 'custom' | string;
author?: string;
play_count?: string;
visit_count?: number;
is_public?: boolean;
}
export interface AppPlaylist {
id: string;
scope: PlaylistScope;
source: string;
kind?: 'playlist' | 'album';
info: PlaylistInfo;
list: any[];
total: number;
}
export interface LocalSong {
id: string;
path: string;
name: string;
artist: string;
albumName: string;
duration: string;
durationSeconds: number;
source: 'local';
type: 'Local';
url: string;
picUrl: string;
lyric: string;
quality: string;
bitrate: number;
sampleRate: number;
channels: number;
size: number;
modifiedAt: number;
addedAt: number;
}
export interface LocalMusicLibrary {
roots: string[];
songs: LocalSong[];
updatedAt: number;
}
export interface ListenTimePoint {
time?: string;
date?: string;
duration: number;
}
export interface ListenTimeStat {
status: 'success' | 'error';
msg?: string;
total?: number;
android_time?: number;
pc_time?: number;
chart_data?: {
daily?: ListenTimePoint[];
weekly?: ListenTimePoint[];
monthly?: ListenTimePoint[];
yearly?: ListenTimePoint[];
};
}
export interface ListenTimeRange {
status: 'success' | 'error';
msg?: string;
data: Array<{ date: string; duration: number }>;
}
export type ListenRankPeriod = 'week' | 'month' | 'year';
export interface ListenRankUser {
id: string;
username: string;
nickname: string;
avatar?: string | null;
}
export interface ListenRankItem {
rank: number;
duration: number;
user: ListenRankUser;
}
export interface ListenRankResponse {
status: 'success' | 'error';
period: ListenRankPeriod;
msg?: string;
data: ListenRankItem[];
}

View File

@@ -15,6 +15,7 @@ export interface Song {
artist: string; artist: string;
duration: string; duration: string;
source: string; source: string;
lyric?: string;
quality?: string; // default 'auto' quality?: string; // default 'auto'
albumId?: string | null; albumId?: string | null;
albumName?: string | null; albumName?: string | null;

View File

@@ -15,6 +15,7 @@ type LyricData = {
yrc?: string yrc?: string
qrc?: string qrc?: string
lrc?: string lrc?: string
plain?: string
lyric?: string lyric?: string
translate?: string translate?: string
translatedLyric?: string translatedLyric?: string
@@ -136,7 +137,7 @@ function normalizeLyricData(input: unknown): LyricData | null {
} }
const format = detectLyricFormat(raw) const format = detectLyricFormat(raw)
return format ? { [format]: raw } : null return format ? { [format]: raw } : { plain: raw }
} }
if (!input || typeof input !== 'object') return null if (!input || typeof input !== 'object') return null
@@ -160,6 +161,10 @@ function normalizeLyricData(input: unknown): LyricData | null {
[format]: data.lyric, [format]: data.lyric,
} }
} }
return {
...data,
plain: data.lyric,
}
} }
return data return data
@@ -171,9 +176,33 @@ function parsePrimaryLyric(data: LyricData): LyricLine[] {
if (typeof data.yrc === 'string') return parseYrc(data.yrc) if (typeof data.yrc === 'string') return parseYrc(data.yrc)
if (typeof data.qrc === 'string') return parseQrc(data.qrc) if (typeof data.qrc === 'string') return parseQrc(data.qrc)
if (typeof data.lrc === 'string') return parseLrc(data.lrc) if (typeof data.lrc === 'string') return parseLrc(data.lrc)
if (typeof data.plain === 'string') return parsePlainLyric(data.plain)
return [] return []
} }
function parsePlainLyric(raw: string): LyricLine[] {
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((text, index) => {
const startTime = index * defaultLineDuration
return {
startTime,
endTime: startTime + defaultLineDuration,
words: [{
word: text,
startTime,
endTime: startTime + defaultLineDuration,
}],
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false,
}
})
}
function lineText(line: LyricLine): string { function lineText(line: LyricLine): string {
return line.words.map((word) => word.word).join('').trim() return line.words.map((word) => word.word).join('').trim()
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
<template>
<div class="listen-rank-view">
<section class="rank-hero">
<div>
<div class="eyebrow">
<Icon icon="lucide:trophy" />
<span>LISTEN RANK</span>
</div>
<h1>排行</h1>
<p>{{ authStore.isLoggedIn ? '看看本周、本月和本年谁听得最久。' : '登录后查看云端听歌时长排行榜。' }}</p>
</div>
<button v-if="!authStore.isLoggedIn" class="primary-btn" @click="openLoginDialog">
<Icon icon="lucide:log-in" />
登录账号
</button>
</section>
<template v-if="authStore.isLoggedIn">
<div class="rank-tabs">
<button
v-for="option in rankOptions"
:key="option.value"
:class="{ active: rankPeriod === option.value }"
@click="rankPeriod = option.value"
>
{{ option.label }}
</button>
</div>
<section class="rank-panel">
<div class="rank-panel-head">
<div>
<h2>{{ rankPeriodLabel }}听歌时长</h2>
<p>只展示前 200 从新版记录开始计入</p>
</div>
<button class="refresh-btn" :disabled="loadingRank" @click="loadRank">
<Icon :icon="loadingRank ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loadingRank }" />
</button>
</div>
<div v-if="loadingRank" class="rank-loading">
<Icon icon="lucide:loader-2" class="spin" />
<span>加载排行榜...</span>
</div>
<div v-else-if="rankError" class="rank-empty">{{ rankError }}</div>
<div v-else-if="rankItems.length === 0" class="rank-empty">
这个周期还没有排行榜数据更新后产生新的听歌记录就会出现
</div>
<div v-else class="rank-list">
<button v-for="item in rankItems" :key="item.user.id" class="rank-row" @click="openUserProfile(item.user.id)">
<div class="rank-number" :class="`top-${Math.min(item.rank, 3)}`">{{ item.rank }}</div>
<img v-if="item.user.avatar" class="rank-avatar" :src="item.user.avatar" alt="" />
<div v-else class="rank-avatar fallback">{{ rankInitial(item.user.nickname || item.user.username) }}</div>
<div class="rank-user">
<strong>{{ item.user.nickname || item.user.username }}</strong>
<span>{{ item.user.username }}</span>
</div>
<strong class="rank-duration">{{ formatTime(item.duration) }}</strong>
</button>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../stores/auth'
import type { ListenRankPeriod, ListenRankResponse } from '../types/electron'
const authStore = useAuthStore()
const router = useRouter()
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
const rankPeriod = ref<ListenRankPeriod>('week')
const rankData = ref<ListenRankResponse | null>(null)
const loadingRank = ref(false)
const rankError = ref('')
const rankOptions: Array<{ label: string; value: ListenRankPeriod }> = [
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '本年', value: 'year' },
]
const rankItems = computed(() => rankData.value?.data || [])
const rankPeriodLabel = computed(() => rankOptions.find((item) => item.value === rankPeriod.value)?.label || '本周')
const rankInitial = (name: string) => (name || 'Q').trim().slice(0, 1).toUpperCase()
const formatTime = (ms: number) => {
const totalMinutes = Math.floor(ms / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
}
const openUserProfile = (userId: string) => {
if (!userId) return
router.push({ name: 'UserProfile', params: { id: userId } })
}
const loadRank = async () => {
if (!authStore.isLoggedIn) return
loadingRank.value = true
rankError.value = ''
try {
const result = await window.electronAPI.stats.getListenRank(rankPeriod.value, 200)
if (result.status === 'error') {
rankError.value = result.msg || '排行榜暂时不可用'
rankData.value = null
} else {
rankData.value = result
}
} catch (err: any) {
rankError.value = err?.message || '排行榜暂时不可用'
rankData.value = null
} finally {
loadingRank.value = false
}
}
watch([rankPeriod, () => authStore.isLoggedIn], loadRank, { immediate: true })
</script>
<style scoped>
.listen-rank-view {
min-height: 100%;
padding: 28px 32px 148px;
box-sizing: border-box;
color: var(--color-text-primary);
}
.rank-hero,
.rank-panel {
border-radius: 28px;
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.rank-hero {
min-height: 170px;
padding: 30px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
background:
radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--color-accent) 20%, transparent), transparent 38%),
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 58%),
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
}
.eyebrow,
.rank-tabs,
.rank-panel-head,
.rank-row {
display: flex;
align-items: center;
}
.eyebrow {
gap: 8px;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 760;
}
h1 {
margin-top: 14px;
font-size: 40px;
line-height: 1.1;
}
p {
margin-top: 10px;
color: var(--color-text-secondary);
}
.primary-btn,
.refresh-btn {
min-height: 40px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.primary-btn {
padding: 0 16px;
background: var(--color-accent-gradient);
color: white;
}
.rank-tabs {
width: fit-content;
gap: 4px;
margin: 18px 0;
padding: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
}
.rank-tabs button {
height: 34px;
min-width: 72px;
border-radius: 999px;
color: var(--color-text-secondary);
}
.rank-tabs button.active {
background: var(--color-bg-primary);
color: var(--color-accent);
}
.rank-panel {
padding: 24px;
}
.rank-panel-head {
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.rank-panel-head h2 {
font-size: 20px;
}
.rank-panel-head p {
margin-top: 5px;
font-size: 13px;
}
.refresh-btn {
width: 40px;
padding: 0;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-primary);
}
.rank-list {
display: grid;
gap: 8px;
}
.rank-row {
min-height: 58px;
width: 100%;
gap: 12px;
padding: 8px 10px;
border-radius: 18px;
background: color-mix(in srgb, var(--color-bg-primary) 56%, transparent);
border: 0;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease;
}
.rank-row:hover {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.rank-number {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--color-text-muted);
font-weight: 800;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.rank-number.top-1 {
color: #fff;
background: linear-gradient(135deg, #f7c86a, #f08b5f);
}
.rank-number.top-2 {
color: #fff;
background: linear-gradient(135deg, #9fb1c8, #7486a6);
}
.rank-number.top-3 {
color: #fff;
background: linear-gradient(135deg, #d69a7a, #a96e57);
}
.rank-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
object-fit: cover;
flex: 0 0 auto;
}
.rank-avatar.fallback {
display: grid;
place-items: center;
background: var(--color-accent-gradient);
color: #fff;
font-weight: 800;
}
.rank-user {
min-width: 0;
flex: 1;
display: grid;
gap: 3px;
}
.rank-user strong,
.rank-user span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-user span,
.rank-empty,
.rank-loading {
color: var(--color-text-muted);
font-size: 13px;
}
.rank-duration {
color: var(--color-accent);
white-space: nowrap;
}
.rank-empty,
.rank-loading {
min-height: 220px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
text-align: center;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 920px) {
.rank-hero {
display: block;
}
.primary-btn {
margin-top: 18px;
}
}
</style>

View File

@@ -0,0 +1,606 @@
<template>
<div class="listen-stats-view">
<section class="stats-hero">
<div>
<div class="eyebrow">
<Icon icon="lucide:activity" />
<span>LISTEN FOOTPRINT</span>
</div>
<h1>听歌足迹</h1>
<p>{{ authStore.isLoggedIn ? '看看这一段时间音乐陪你走过了多久。' : '登录后同步查看云端听歌时长。' }}</p>
</div>
<button v-if="!authStore.isLoggedIn" class="primary-btn" @click="openLoginDialog">
<Icon icon="lucide:log-in" />
登录账号
</button>
</section>
<template v-if="authStore.isLoggedIn">
<div class="tabs">
<button :class="{ active: activeTab === 'week' }" @click="activeTab = 'week'"></button>
<button :class="{ active: activeTab === 'month' }" @click="activeTab = 'month'"></button>
<button :class="{ active: activeTab === 'year' }" @click="activeTab = 'year'"></button>
</div>
<Transition name="stats-page" mode="out-in">
<section v-if="activeTab === 'week'" :key="`week-${weekOffset}`" class="stats-panel">
<div class="date-switcher">
<button :disabled="!canGoPrevWeek" @click="weekOffset--"><Icon icon="lucide:chevron-left" /></button>
<strong>{{ weekTitle }}</strong>
<button :disabled="weekOffset >= 0" @click="weekOffset++"><Icon icon="lucide:chevron-right" /></button>
</div>
<div v-if="loadingRange" class="loading-state"><Icon icon="lucide:loader-2" class="spin" />加载中</div>
<div v-else class="bar-chart">
<div v-for="item in weeklyBars" :key="item.label" class="bar-item">
<div class="bar-value">{{ formatShortTime(item.duration) }}</div>
<div class="bar-track">
<div class="bar-fill" :style="{ height: `${item.percent}%` }"></div>
</div>
<div class="bar-label">{{ item.label }}</div>
</div>
</div>
<div class="summary-line">
<span>本周合计</span>
<strong>{{ formatTime(weeklyTotal) }}</strong>
</div>
</section>
<section v-else-if="activeTab === 'month'" :key="`month-${monthOffset}`" class="stats-panel">
<div class="date-switcher">
<button :disabled="!canGoPrevMonth" @click="monthOffset--"><Icon icon="lucide:chevron-left" /></button>
<strong>{{ monthTitle }}</strong>
<button :disabled="monthOffset >= 0" @click="monthOffset++"><Icon icon="lucide:chevron-right" /></button>
</div>
<div v-if="loadingRange" class="loading-state"><Icon icon="lucide:loader-2" class="spin" />加载中</div>
<template v-else>
<div class="weekday-row">
<span v-for="day in weekdays" :key="day">{{ day }}</span>
</div>
<div class="calendar-grid">
<div v-for="blank in monthLeadingBlanks" :key="`blank-${blank}`" class="day-cell blank"></div>
<button
v-for="day in monthDays"
:key="day.date"
class="day-cell"
:class="{ active: day.duration > 0, selected: selectedDate === day.date }"
:style="{ '--intensity': day.intensity }"
@click="selectedDate = selectedDate === day.date ? '' : day.date"
>
{{ day.day }}
</button>
</div>
<div class="summary-line">
<span>{{ selectedMonthText }}</span>
<strong>{{ formatTime(selectedMonthDuration) }}</strong>
</div>
</template>
</section>
<section v-else key="year" class="year-layout">
<div class="total-card">
<span>累计畅听</span>
<strong>{{ formatTime(totalStat?.total || 0) }}</strong>
<small>{{ totalStat?.status === 'error' ? totalStat.msg || '暂无统计数据' : '来自 Android 与 PC 的同步统计' }}</small>
</div>
<div class="platform-grid">
<div class="platform-card">
<span>Android</span>
<strong>{{ formatTime(totalStat?.android_time || 0) }}</strong>
</div>
<div class="platform-card">
<span>PC</span>
<strong>{{ formatTime(totalStat?.pc_time || 0) }}</strong>
</div>
</div>
<section class="stats-panel compact">
<div class="panel-heading">
<h2>近五年</h2>
<button class="ghost-btn" :disabled="loadingTotal" @click="loadTotal">
<Icon :icon="loadingTotal ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loadingTotal }" />
</button>
</div>
<div class="year-bars">
<div v-for="item in yearlyBars" :key="item.label" class="year-row">
<span>{{ item.label }}</span>
<div class="year-track"><div :style="{ width: `${item.percent}%` }"></div></div>
<strong>{{ formatShortTime(item.duration) }}</strong>
</div>
</div>
</section>
</section>
</Transition>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../stores/auth'
import type { ListenTimeRange, ListenTimeStat } from '../types/electron'
type TabMode = 'week' | 'month' | 'year'
const authStore = useAuthStore()
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
const activeTab = ref<TabMode>('week')
const weekOffset = ref(0)
const monthOffset = ref(0)
const rangeData = ref<ListenTimeRange | null>(null)
const totalStat = ref<ListenTimeStat | null>(null)
const loadingRange = ref(false)
const loadingTotal = ref(false)
const selectedDate = ref('')
const minDate = new Date(2026, 2, 1)
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
const pad = (value: number) => String(value).padStart(2, '0')
const formatDate = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
const monthDate = computed(() => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth() + monthOffset.value, 1)
})
const weekStart = computed(() => {
const base = addDays(new Date(), weekOffset.value * 7)
const day = base.getDay() || 7
return addDays(base, 1 - day)
})
const weekEnd = computed(() => addDays(weekStart.value, 6))
const weekTitle = computed(() => `${weekStart.value.getMonth() + 1}.${pad(weekStart.value.getDate())} - ${weekEnd.value.getMonth() + 1}.${pad(weekEnd.value.getDate())}`)
const monthTitle = computed(() => `${monthDate.value.getFullYear()}${monthDate.value.getMonth() + 1}`)
const canGoPrevWeek = computed(() => addDays(weekStart.value, -7) >= minDate)
const canGoPrevMonth = computed(() => new Date(monthDate.value.getFullYear(), monthDate.value.getMonth() - 1, 1) >= minDate)
const formatTime = (ms: number) => {
const totalMinutes = Math.floor(ms / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
}
const formatShortTime = (ms: number) => {
const minutes = Math.round(ms / 60000)
if (minutes <= 0) return '0m'
if (minutes < 60) return `${minutes}m`
return `${Math.round(minutes / 60)}h`
}
const rangeMap = computed(() => new Map((rangeData.value?.data || []).map((item) => [item.date, item.duration])))
const weeklyTotal = computed(() => weeklyBars.value.reduce((sum, item) => sum + item.duration, 0))
const weeklyBars = computed(() => {
const values = weekdays.map((label, index) => ({
label,
duration: rangeMap.value.get(formatDate(addDays(weekStart.value, index))) || 0,
}))
const max = Math.max(1, ...values.map((item) => item.duration))
return values.map((item) => ({ ...item, percent: Math.max(4, (item.duration / max) * 100) }))
})
const monthLeadingBlanks = computed(() => {
const day = monthDate.value.getDay() || 7
return Math.max(0, day - 1)
})
const monthDays = computed(() => {
const year = monthDate.value.getFullYear()
const month = monthDate.value.getMonth()
const count = new Date(year, month + 1, 0).getDate()
const durations = Array.from({ length: count }, (_, index) => {
const date = formatDate(new Date(year, month, index + 1))
return { date, day: index + 1, duration: rangeMap.value.get(date) || 0 }
})
const max = Math.max(1, ...durations.map((item) => item.duration))
return durations.map((item) => ({
...item,
intensity: item.duration > 0 ? String(Math.max(0.22, item.duration / max)) : '0',
}))
})
const selectedMonthText = computed(() => selectedDate.value ? `${selectedDate.value} 听歌` : '本月合计')
const selectedMonthDuration = computed(() => {
if (selectedDate.value) return rangeMap.value.get(selectedDate.value) || 0
return monthDays.value.reduce((sum, day) => sum + day.duration, 0)
})
const yearlyBars = computed(() => {
const items = totalStat.value?.chart_data?.yearly || []
const max = Math.max(1, ...items.map((item) => item.duration))
return items.map((item) => ({
label: item.time || '-',
duration: item.duration,
percent: Math.max(4, (item.duration / max) * 100),
}))
})
const loadRange = async () => {
if (!authStore.isLoggedIn) return
loadingRange.value = true
try {
if (activeTab.value === 'week') {
rangeData.value = await window.electronAPI.stats.getListenRange(formatDate(weekStart.value), formatDate(weekEnd.value))
} else {
const start = monthDate.value
const end = new Date(start.getFullYear(), start.getMonth() + 1, 0)
rangeData.value = await window.electronAPI.stats.getListenRange(formatDate(start), formatDate(end))
selectedDate.value = ''
}
} finally {
loadingRange.value = false
}
}
const loadTotal = async () => {
if (!authStore.isLoggedIn) return
loadingTotal.value = true
try {
totalStat.value = await window.electronAPI.stats.getListenTime(1)
} finally {
loadingTotal.value = false
}
}
watch([activeTab, weekOffset, monthOffset, () => authStore.isLoggedIn], () => {
if (activeTab.value === 'year') loadTotal()
else loadRange()
}, { immediate: true })
onMounted(loadTotal)
</script>
<style scoped>
.listen-stats-view {
min-height: 100%;
padding: 28px 32px 148px;
box-sizing: border-box;
color: var(--color-text-primary);
}
.stats-hero,
.stats-panel,
.total-card,
.platform-card {
border-radius: 28px;
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.stats-hero {
min-height: 170px;
padding: 30px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 56%),
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
}
.eyebrow,
.tabs,
.date-switcher,
.summary-line,
.panel-heading,
.year-row {
display: flex;
align-items: center;
}
.eyebrow {
gap: 8px;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 760;
}
h1 {
margin-top: 14px;
font-size: 40px;
line-height: 1.1;
}
p {
margin-top: 10px;
color: var(--color-text-secondary);
}
.primary-btn,
.ghost-btn {
min-height: 40px;
padding: 0 16px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.primary-btn {
background: var(--color-accent-gradient);
color: white;
}
.tabs {
width: fit-content;
gap: 4px;
margin: 18px 0;
padding: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
}
.tabs button {
height: 34px;
min-width: 72px;
border-radius: 999px;
color: var(--color-text-secondary);
}
.tabs button.active {
background: var(--color-bg-primary);
color: var(--color-accent);
}
.stats-panel {
padding: 24px;
}
.date-switcher {
justify-content: center;
gap: 18px;
margin-bottom: 22px;
}
.date-switcher button {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
padding: 0;
color: var(--color-text-secondary);
line-height: 0;
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
}
.date-switcher button svg {
width: 20px;
height: 20px;
display: block;
}
.date-switcher button:not(:disabled):hover {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-text-primary);
}
.bar-chart {
height: 260px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
}
.bar-item {
min-width: 0;
display: grid;
grid-template-rows: 28px 1fr 24px;
gap: 8px;
text-align: center;
}
.bar-value,
.bar-label {
color: var(--color-text-muted);
font-size: 12px;
}
.bar-track {
display: flex;
align-items: flex-end;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
overflow: hidden;
}
.bar-fill {
width: 100%;
min-height: 4px;
border-radius: inherit;
background: var(--color-accent-gradient);
transition: height 260ms cubic-bezier(0.2, 0, 0, 1);
}
.summary-line {
justify-content: space-between;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--color-border);
}
.summary-line span,
.platform-card span,
.total-card span,
.total-card small {
color: var(--color-text-muted);
}
.summary-line strong {
color: var(--color-accent);
}
.weekday-row,
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.weekday-row {
margin-bottom: 10px;
color: var(--color-text-muted);
font-size: 12px;
text-align: center;
}
.day-cell {
aspect-ratio: 1;
border-radius: 12px;
background: color-mix(in srgb, var(--color-bg-primary) 76%, transparent);
color: var(--color-text-secondary);
transition: background-color 180ms ease, color 180ms ease, outline-color 180ms ease;
}
.day-cell.active {
background: color-mix(in srgb, var(--color-accent) calc(var(--intensity) * 46%), var(--color-bg-primary));
color: var(--color-text-primary);
}
.day-cell.selected {
outline: 2px solid var(--color-accent);
}
.day-cell.blank {
pointer-events: none;
opacity: 0;
}
.year-layout {
display: grid;
grid-template-columns: minmax(280px, 0.8fr) minmax(320px, 1.2fr);
gap: 18px;
}
.total-card,
.platform-card {
padding: 24px;
}
.total-card {
min-height: 230px;
display: flex;
flex-direction: column;
justify-content: flex-end;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 22%, transparent), transparent 62%),
color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
}
.total-card strong {
margin-top: 10px;
font-size: 42px;
line-height: 1;
}
.total-card small {
margin-top: 12px;
}
.platform-grid {
display: grid;
gap: 12px;
}
.platform-card strong {
display: block;
margin-top: 10px;
font-size: 24px;
}
.stats-panel.compact {
grid-column: 1 / -1;
}
.panel-heading {
justify-content: space-between;
margin-bottom: 18px;
}
.panel-heading h2 {
font-size: 18px;
}
.ghost-btn {
width: 38px;
padding: 0;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-primary);
}
.year-bars {
display: grid;
gap: 12px;
}
.year-row {
grid-template-columns: 58px minmax(0, 1fr) 74px;
gap: 12px;
}
.year-row span,
.year-row strong {
font-size: 13px;
color: var(--color-text-muted);
}
.year-track {
height: 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
overflow: hidden;
}
.year-track div {
height: 100%;
border-radius: inherit;
background: var(--color-accent-gradient);
transition: width 260ms cubic-bezier(0.2, 0, 0, 1);
}
.loading-state {
min-height: 260px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
color: var(--color-text-muted);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.spin {
animation: spin 900ms linear infinite;
}
.stats-page-enter-active,
.stats-page-leave-active {
transition:
opacity 180ms ease,
transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.stats-page-enter-from,
.stats-page-leave-to {
opacity: 0;
transform: translateY(8px);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 920px) {
.stats-hero,
.year-layout {
display: block;
}
.primary-btn,
.platform-grid,
.stats-panel.compact {
margin-top: 18px;
}
}
</style>

View File

@@ -0,0 +1,579 @@
<template>
<div class="together-page">
<section class="room-hero" :style="{ '--room-hero-bg': `url('${roomHeroBg}')` }">
<div class="hero-copy">
<div class="eyebrow">
<Icon icon="lucide:radio-tower" />
<span>{{ together.connected ? '房间在线' : '一起听' }}</span>
</div>
<h1>{{ together.connected ? `房间 ${together.roomId}` : '同步播放室' }}</h1>
<p>{{ statusText }}</p>
</div>
<div class="hero-actions">
<button v-if="!authStore.isLoggedIn" class="primary-action" @click="openLoginDialog">
<Icon icon="lucide:log-in" />
登录
</button>
<template v-else-if="!together.connected">
<button class="primary-action" :disabled="together.connecting" @click="createRoom">
<Icon :icon="together.connecting ? 'lucide:loader-2' : 'lucide:plus'" :class="{ spin: together.connecting }" />
创建房间
</button>
<button class="soft-action" :disabled="together.connecting || !normalizedJoinCode" @click="joinRoom">
<Icon icon="lucide:door-open" />
加入
</button>
</template>
<template v-else>
<button class="soft-action" :disabled="!together.canControl" @click="together.sendCurrentSnapshot()">
<Icon icon="lucide:refresh-cw" />
同步
</button>
<button class="danger-action" @click="leaveRoom">
<Icon icon="lucide:log-out" />
{{ together.isHost ? '关闭房间' : '离开' }}
</button>
</template>
</div>
</section>
<div class="room-grid">
<section class="control-panel">
<div class="panel-heading">
<div>
<h2>房间</h2>
<span>{{ permissionText }}</span>
</div>
<div class="mode-toggle" :class="{ disabled: together.connected }">
<button :class="{ active: roomMode === 'multi' }" :disabled="together.connected" @click="roomMode = 'multi'">
多人
</button>
<button :class="{ active: roomMode === 'dual' }" :disabled="together.connected" @click="roomMode = 'dual'">
双人
</button>
</div>
</div>
<div v-if="!together.connected" class="join-box">
<label>
<span>房间码</span>
<input v-model="joinText" placeholder="输入或粘贴分享文本" />
</label>
<div class="hint-row">
<span>{{ normalizedJoinCode || '等待房间码' }}</span>
</div>
</div>
<div v-else class="room-code">
<span>{{ together.roomId }}</span>
<button @click="copyRoomCode">
<Icon icon="lucide:copy" />
</button>
</div>
<div class="now-row">
<div class="song-cover">
<img v-if="player.currentSong?.picUrl" :src="player.currentSong.picUrl" alt="" />
<Icon v-else icon="lucide:music-2" />
</div>
<div class="song-copy">
<span>{{ player.currentSong?.name || '未播放' }}</span>
<small>{{ player.currentSong?.artist || 'QZ Music' }}</small>
</div>
<div class="play-state" :class="{ playing: player.isPlaying }">
{{ player.isPlaying ? '播放中' : '已暂停' }}
</div>
</div>
</section>
<section class="members-panel">
<div class="panel-heading">
<div>
<h2>成员</h2>
<span>{{ together.userList.length }} </span>
</div>
</div>
<div class="member-list">
<div v-for="uid in together.userList" :key="uid" class="member-item">
<div class="avatar">{{ uid.slice(0, 1).toUpperCase() }}</div>
<div class="member-copy">
<span>{{ uid === authStore.state.userInfo?.id ? '我' : uid }}</span>
<small>{{ permissionLabel(together.allPermissions[uid] ?? 0) }}</small>
</div>
<button
v-if="together.isHost && uid !== authStore.state.userInfo?.id"
class="permission-btn"
@click="togglePermission(uid)"
>
{{ (together.allPermissions[uid] ?? 0) >= 1 ? '旁听' : '控制' }}
</button>
</div>
<div v-if="together.userList.length === 0" class="empty-state">暂无成员</div>
</div>
</section>
<section class="queue-panel">
<div class="panel-heading">
<div>
<h2>播放队列</h2>
<span>{{ player.playlist.length }} </span>
</div>
</div>
<div class="queue-list">
<div
v-for="(song, index) in player.playlist"
:key="`${song.source}:${song.id}:${index}`"
class="queue-item"
:class="{ active: index === player.currentIndex }"
>
<span class="queue-index">{{ String(index + 1).padStart(2, '0') }}</span>
<img v-if="song.picUrl" :src="song.picUrl" alt="" />
<div v-else class="queue-placeholder"><Icon icon="lucide:music" /></div>
<div class="queue-copy">
<span>{{ song.name }}</span>
<small>{{ song.artist }}</small>
</div>
</div>
<div v-if="player.playlist.length === 0" class="empty-state">队列为空</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import roomHeroBg from '../assets/long_width_bg.png'
import { useAuthStore } from '../stores/auth'
import { useListenTogetherStore } from '../stores/listenTogether'
import { usePlayerStore } from '../stores/player'
const authStore = useAuthStore()
const together = useListenTogetherStore()
const player = usePlayerStore()
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
const roomMode = ref<'dual' | 'multi'>('multi')
const joinText = ref('')
const normalizedJoinCode = computed(() => {
const text = joinText.value.trim()
const shared = text.match(/#([a-z0-9]{6})#/i)
if (shared) return shared[1].toLowerCase()
const plain = text.match(/[a-z0-9]{6}/i)
return plain ? plain[0].toLowerCase() : ''
})
const statusText = computed(() => {
if (!authStore.isLoggedIn) return '登录后可创建或加入房间'
if (together.connecting) return '正在连接房间'
if (together.connected) return together.canControl ? '当前设备可控制播放' : '当前设备正在跟随播放'
return '创建房间或加入已有房间'
})
const permissionText = computed(() => permissionLabel(together.permissionLevel))
const permissionLabel = (level: number) => {
if (level >= 2) return '房主'
if (level >= 1) return '可控制'
return '旁听'
}
const createRoom = () => {
if (!authStore.isLoggedIn) return openLoginDialog()
together.createRoom(roomMode.value)
}
const joinRoom = () => {
if (!authStore.isLoggedIn) return openLoginDialog()
if (!normalizedJoinCode.value) return
together.joinRoom(normalizedJoinCode.value)
}
const leaveRoom = () => {
together.disconnect(true)
}
const copyRoomCode = async () => {
if (!together.roomId) return
const text = `加入一起听歌#${together.roomId}#`
await navigator.clipboard.writeText(text)
ElMessage.success('房间码已复制')
}
const togglePermission = (uid: string) => {
const current = together.allPermissions[uid] ?? 0
together.changePermission(uid, current >= 1 ? 0 : 1)
}
</script>
<style scoped>
.together-page {
min-height: 100%;
padding: 28px 32px 132px;
color: var(--color-text-primary);
}
.room-hero {
min-height: 196px;
border-radius: 30px;
padding: 30px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
position: relative;
overflow: hidden;
isolation: isolate;
background: color-mix(in srgb, var(--color-bg-secondary) 82%, transparent);
box-shadow: none;
}
.room-hero::before {
content: "";
position: absolute;
inset: -18px;
z-index: -2;
background: var(--room-hero-bg) center / cover no-repeat;
filter: blur(12px) saturate(1.08);
transform: scale(1.04);
}
.room-hero::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
linear-gradient(90deg, rgba(18, 20, 32, 0.72) 0%, rgba(18, 20, 32, 0.38) 52%, rgba(18, 20, 32, 0.18) 100%),
linear-gradient(180deg, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.02) 100%);
}
.eyebrow,
.hero-actions,
.panel-heading,
.now-row,
.member-item,
.queue-item,
.room-code,
.hint-row {
display: flex;
align-items: center;
}
.eyebrow {
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
}
.hero-copy h1 {
margin-top: 14px;
font-size: 36px;
line-height: 1.1;
letter-spacing: 0;
color: #fff;
}
.hero-copy p {
margin-top: 10px;
color: rgba(255, 255, 255, 0.78);
}
.hero-actions {
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.primary-action,
.soft-action,
.danger-action,
.permission-btn,
.room-code button {
height: 40px;
border-radius: 999px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
font-weight: 680;
}
.primary-action {
background: var(--color-accent-gradient);
color: #fff;
}
.soft-action,
.permission-btn,
.room-code button {
background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent);
color: var(--color-text-primary);
}
.danger-action {
background: rgba(255, 85, 85, 0.14);
color: #ff7070;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.room-grid {
display: grid;
grid-template-columns: minmax(360px, 1.1fr) minmax(280px, 0.9fr);
gap: 18px;
margin-top: 18px;
}
.control-panel,
.members-panel,
.queue-panel {
border-radius: 24px;
padding: 22px;
background: color-mix(in srgb, var(--color-bg-primary) 68%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.queue-panel {
grid-column: 1 / -1;
}
.panel-heading {
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.panel-heading h2 {
font-size: 18px;
letter-spacing: 0;
}
.panel-heading span,
.member-copy small,
.queue-copy small,
.song-copy small,
.hint-row {
color: var(--color-text-muted);
font-size: 12px;
}
.mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.mode-toggle button {
min-width: 62px;
height: 30px;
border-radius: 999px;
color: var(--color-text-secondary);
}
.mode-toggle button.active {
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.join-box label {
display: block;
}
.join-box label span {
display: block;
margin-bottom: 8px;
font-size: 12px;
color: var(--color-text-muted);
}
.join-box input {
width: 100%;
height: 44px;
border-radius: 16px;
padding: 0 14px;
background: color-mix(in srgb, var(--color-bg-secondary) 82%, transparent);
outline: none;
}
.hint-row {
justify-content: space-between;
min-height: 34px;
}
.room-code {
justify-content: space-between;
gap: 12px;
height: 50px;
padding-left: 16px;
border-radius: 18px;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.room-code span {
font-size: 24px;
font-weight: 800;
letter-spacing: 0;
}
.room-code button {
width: 42px;
padding: 0;
margin-right: 4px;
}
.now-row {
gap: 14px;
margin-top: 18px;
}
.song-cover,
.avatar,
.queue-placeholder {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
overflow: hidden;
flex-shrink: 0;
}
.song-cover {
width: 58px;
height: 58px;
border-radius: 18px;
}
.song-cover img,
.queue-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.song-copy,
.member-copy,
.queue-copy {
min-width: 0;
flex: 1;
}
.song-copy span,
.member-copy span,
.queue-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.play-state {
font-size: 12px;
color: var(--color-text-muted);
}
.play-state.playing {
color: var(--color-accent);
}
.member-list,
.queue-list {
display: grid;
gap: 8px;
}
.member-item {
gap: 12px;
min-height: 54px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
font-weight: 800;
}
.permission-btn {
height: 32px;
padding: 0 12px;
}
.queue-list {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.queue-item {
gap: 12px;
min-width: 0;
height: 62px;
padding: 8px 10px;
border-radius: 18px;
background: color-mix(in srgb, var(--color-bg-secondary) 66%, transparent);
}
.queue-item.active {
background: color-mix(in srgb, var(--color-accent) 13%, transparent);
}
.queue-index {
width: 24px;
font-size: 11px;
color: var(--color-text-muted);
}
.queue-item img,
.queue-placeholder {
width: 42px;
height: 42px;
border-radius: 13px;
}
.empty-state {
min-height: 72px;
display: grid;
place-items: center;
color: var(--color-text-muted);
font-size: 13px;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 1050px) {
.room-hero,
.room-grid {
display: block;
}
.hero-actions {
justify-content: flex-start;
margin-top: 24px;
}
.members-panel,
.queue-panel {
margin-top: 18px;
}
}
</style>

View File

@@ -1,54 +1,683 @@
<template> <template>
<div class="view-container local-view"> <div class="local-view">
<h1 class="view-title">Local Files</h1> <section class="local-hero">
<div class="empty-state"> <div>
<div class="icon-box"> <div class="eyebrow">
<Icon icon="lucide:music" width="48" height="48" /> <Icon icon="lucide:hard-drive" />
<span>LOCAL MUSIC</span>
</div>
<h1>本地音乐</h1>
<p>扫描电脑里的音频文件按艺术家专辑和修改时间整理播放</p>
</div> </div>
<p>No local files scanned yet.</p> <div class="hero-actions">
<button class="action-btn">Scan Folder</button> <button class="soft-btn" :disabled="scanning" @click="pickFolders">
</div> <Icon icon="lucide:folder-plus" />
添加文件夹
</button>
<button class="primary-btn" :disabled="scanning || roots.length === 0" @click="scanRoots()">
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:scan-line'" :class="{ spin: scanning }" />
{{ scanning ? '扫描中' : '重新扫描' }}
</button>
</div>
</section>
<section class="stats-row">
<div class="stat-item">
<span>{{ songs.length }}</span>
<small>歌曲</small>
</div>
<div class="stat-item">
<span>{{ artistCount }}</span>
<small>艺术家</small>
</div>
<div class="stat-item">
<span>{{ albumCount }}</span>
<small>专辑</small>
</div>
<div class="stat-item wide" :title="roots.join('\n')">
<span>{{ roots.length || 0 }}</span>
<small>{{ roots.length ? roots[0] : '未选择目录' }}</small>
</div>
</section>
<section class="root-list" v-if="roots.length > 0">
<div class="root-title">扫描目录</div>
<button
v-for="root in roots"
:key="root"
class="root-chip"
:title="root"
:disabled="scanning"
@click="removeRoot(root)"
>
<span>{{ root }}</span>
<Icon icon="lucide:x" />
</button>
</section>
<section class="toolbar">
<div class="search-box">
<Icon icon="lucide:search" />
<input v-model="query" placeholder="搜索歌曲、艺术家、专辑" />
</div>
<div class="segmented">
<button :class="{ active: groupBy === 'all' }" @click="groupBy = 'all'">全部</button>
<button :class="{ active: groupBy === 'artist' }" @click="groupBy = 'artist'">艺术家</button>
<button :class="{ active: groupBy === 'album' }" @click="groupBy = 'album'">专辑</button>
</div>
<select v-model="sortMode" class="sort-select">
<option value="az">A-Z 正序</option>
<option value="za">Z-A 倒序</option>
<option value="modified-desc">修改日期 新到旧</option>
<option value="modified-asc">修改日期 旧到新</option>
</select>
</section>
<section v-if="scanning" class="state-panel">
<Icon icon="lucide:loader-2" class="spin" />
<span>正在读取音频标签...</span>
</section>
<section v-else-if="songs.length === 0" class="state-panel">
<Icon icon="lucide:music-2" />
<span>还没有本地歌曲</span>
<button class="primary-btn compact" @click="pickFolders">扫描文件夹</button>
</section>
<section v-else class="local-content">
<template v-if="groupBy === 'all'">
<div class="list-header">
<span>#</span>
<span></span>
<span>标题</span>
<span>专辑</span>
<span>时长</span>
<span></span>
</div>
<TransitionGroup name="list-shift" tag="div" class="song-list">
<SongTile
v-for="(song, index) in pagedSongs"
:key="song.id"
:song="song"
:display-index="pageStart + index + 1"
removable
reserve-action
@play="playSong(pageStart + index)"
@remove="removeSong(song.id)"
/>
</TransitionGroup>
</template>
<template v-else>
<TransitionGroup name="list-shift" tag="div" class="group-transition-list">
<div v-for="group in pagedGroups" :key="group.name" class="group-block">
<button class="group-header" @click="toggleGroup(group.name)">
<div>
<strong>{{ group.name }}</strong>
<span>{{ group.songs.length }} </span>
</div>
<Icon :icon="collapsedGroups.has(group.name) ? 'lucide:chevron-right' : 'lucide:chevron-down'" />
</button>
<div v-if="!collapsedGroups.has(group.name)" class="group-list">
<SongTile
v-for="song in group.songs"
:key="song.id"
:song="song"
:display-index="songGlobalIndex(song)"
removable
reserve-action
@play="playSong(songGlobalIndex(song) - 1)"
@remove="removeSong(song.id)"
/>
</div>
</div>
</TransitionGroup>
</template>
<div class="pagination" v-if="totalPages > 1">
<button class="page-btn" :disabled="currentPage <= 1" @click="currentPage--">
<Icon icon="lucide:chevron-left" />
</button>
<button
v-for="page in visiblePages"
:key="page"
class="page-btn"
:class="{ active: page === currentPage }"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-btn" :disabled="currentPage >= totalPages" @click="currentPage++">
<Icon icon="lucide:chevron-right" />
</button>
</div>
</section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { computed, onMounted, ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import SongTile from '../components/SongTile.vue'
import { usePlayerStore } from '../stores/player'
import type { Song } from '../types/song'
interface LocalSong extends Song {
path: string
albumName: string
durationSeconds: number
quality: string
bitrate: number
sampleRate: number
channels: number
size: number
modifiedAt: number
addedAt: number
}
type GroupMode = 'all' | 'artist' | 'album'
type SortMode = 'az' | 'za' | 'modified-desc' | 'modified-asc'
const playerStore = usePlayerStore()
const songs = ref<LocalSong[]>([])
const roots = ref<string[]>([])
const scanning = ref(false)
const query = ref('')
const groupBy = ref<GroupMode>('all')
const sortMode = ref<SortMode>('az')
const currentPage = ref(1)
const pageSize = 40
const collapsedGroups = ref(new Set<string>())
const toPlainRoots = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value.map((item) => String(item)).filter(Boolean)
}
const pinyinCollator = new Intl.Collator('zh-Hans-CN-u-co-pinyin', {
numeric: true,
sensitivity: 'base',
})
const compareByPinyin = (left = '', right = '') => pinyinCollator.compare(left, right)
const loadLibrary = async () => {
const library = await window.electronAPI.localMusic.getLibrary()
roots.value = library.roots || []
songs.value = (library.songs || []) as LocalSong[]
}
const pickFolders = async () => {
const selected = await window.electronAPI.selectDirectories()
if (selected.length === 0) return
const library = await window.electronAPI.localMusic.setRoots(Array.from(new Set([...toPlainRoots(roots.value), ...toPlainRoots(selected)])))
roots.value = library.roots || []
ElMessage.success('已添加到扫描目录')
}
const removeRoot = async (root: string) => {
const library = await window.electronAPI.localMusic.setRoots(toPlainRoots(roots.value).filter((item) => item !== root))
roots.value = library.roots || []
songs.value = (library.songs || []) as LocalSong[]
ElMessage.success('已从扫描目录移除')
}
const scanRoots = async (nextRoots: unknown = roots.value) => {
scanning.value = true
try {
const library = await window.electronAPI.localMusic.scan(toPlainRoots(nextRoots))
roots.value = library.roots || []
songs.value = (library.songs || []) as LocalSong[]
currentPage.value = 1
ElMessage.success(`扫描完成:${songs.value.length} 首歌曲`)
} catch (error: any) {
console.error('[LocalMusic] scan failed:', error)
ElMessage.error(error?.message || '扫描失败')
} finally {
scanning.value = false
}
}
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const filteredSongs = computed(() => {
const q = normalizedQuery.value
const list = q
? songs.value.filter((song) => (
song.name.toLowerCase().includes(q) ||
song.artist.toLowerCase().includes(q) ||
(song.albumName || '').toLowerCase().includes(q)
))
: songs.value.slice()
return list.sort((a, b) => {
if (sortMode.value === 'modified-desc') return b.modifiedAt - a.modifiedAt
if (sortMode.value === 'modified-asc') return a.modifiedAt - b.modifiedAt
const result = compareByPinyin(a.name, b.name)
return sortMode.value === 'za' ? -result : result
})
})
const artistCount = computed(() => new Set(songs.value.map((song) => song.artist)).size)
const albumCount = computed(() => new Set(songs.value.map((song) => song.albumName)).size)
const totalItems = computed(() => groupBy.value === 'all' ? filteredSongs.value.length : groupedSongs.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize)))
const pageStart = computed(() => (currentPage.value - 1) * pageSize)
const pagedSongs = computed(() => filteredSongs.value.slice(pageStart.value, pageStart.value + pageSize))
const groupedSongs = computed(() => {
const map = new Map<string, LocalSong[]>()
for (const song of filteredSongs.value) {
const key = groupBy.value === 'artist' ? song.artist : song.albumName
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(song)
}
return Array.from(map.entries())
.map(([name, groupSongs]) => ({ name, songs: groupSongs }))
.sort((a, b) => {
const result = compareByPinyin(a.name, b.name)
return sortMode.value === 'za' ? -result : result
})
})
const pagedGroups = computed(() => groupedSongs.value.slice(pageStart.value, pageStart.value + pageSize))
const visiblePages = computed(() => {
const delta = 2
const start = Math.max(1, Math.min(currentPage.value - delta, totalPages.value - delta * 2))
const end = Math.min(totalPages.value, Math.max(currentPage.value + delta, 1 + delta * 2))
const pages: number[] = []
for (let page = start; page <= end; page++) pages.push(page)
return pages
})
const playSong = (globalIndex: number) => {
const song = filteredSongs.value[globalIndex]
if (song) playerStore.playFromList(song, filteredSongs.value)
}
const removeSong = async (id: string) => {
const library = await window.electronAPI.localMusic.remove(id)
songs.value = (library.songs || []) as LocalSong[]
ElMessage.success('已从本地列表移除')
}
const toggleGroup = (name: string) => {
const next = new Set(collapsedGroups.value)
if (next.has(name)) next.delete(name)
else next.add(name)
collapsedGroups.value = next
}
const songGlobalIndex = (song: LocalSong) => filteredSongs.value.findIndex((item) => item.id === song.id) + 1
watch([query, groupBy, sortMode], () => {
currentPage.value = 1
})
watch(totalPages, (pages) => {
if (currentPage.value > pages) currentPage.value = pages
})
onMounted(loadLibrary)
</script> </script>
<style scoped> <style scoped>
.view-title { .local-view {
font-size: 2rem; min-height: 100%;
font-weight: 700; padding: 28px 32px 148px;
margin-bottom: 24px; box-sizing: border-box;
color: var(--color-text-primary);
} }
.empty-state { .local-hero {
height: 400px; min-height: 190px;
display: flex; display: flex;
flex-direction: column; align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 30px;
border-radius: 30px;
position: relative;
overflow: hidden;
isolation: isolate;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 13%, transparent), transparent 52%),
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.local-hero::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(90% 90% at 0% 0%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 58%),
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent);
opacity: 0.8;
}
.eyebrow,
.hero-actions,
.toolbar,
.search-box,
.stats-row,
.pagination,
.group-header {
display: flex;
align-items: center;
}
.eyebrow {
gap: 8px;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 760;
}
h1 {
margin-top: 14px;
font-size: 40px;
line-height: 1.1;
}
p {
margin-top: 10px;
color: var(--color-text-secondary);
}
.hero-actions {
gap: 10px;
flex-wrap: wrap;
}
.primary-btn,
.soft-btn {
min-height: 40px;
padding: 0 16px;
border-radius: 999px;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px;
font-weight: 700;
}
.primary-btn {
background: var(--color-accent-gradient);
color: white;
}
.primary-btn.compact {
min-height: 36px;
}
.soft-btn {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-primary);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 0.18fr)) minmax(240px, 1fr);
gap: 12px;
margin-top: 18px;
}
.root-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.root-title {
color: var(--color-text-muted);
font-size: 13px;
font-weight: 700;
margin-right: 2px;
}
.root-chip {
max-width: min(360px, 100%);
height: 34px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 10px 0 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
color: var(--color-text-secondary);
}
.root-chip:hover:not(:disabled) {
color: var(--color-text-primary);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.root-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-item {
min-width: 0;
min-height: 76px;
padding: 14px 16px;
border-radius: 20px;
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
}
.stat-item span {
display: block;
font-size: 24px;
font-weight: 820;
}
.stat-item small {
display: block;
margin-top: 4px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar {
gap: 12px;
margin-top: 22px;
flex-wrap: wrap;
}
.search-box {
flex: 1;
min-width: 240px;
height: 42px;
gap: 10px;
padding: 0 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.icon-box { .search-box input {
background-color: var(--color-bg-secondary); flex: 1;
padding: 24px; min-width: 0;
border-radius: 50%; outline: none;
margin-bottom: 24px;
} }
.action-btn { .segmented {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
}
.segmented button {
height: 34px;
padding: 0 14px;
border-radius: 999px;
color: var(--color-text-secondary);
}
.segmented button.active {
background: var(--color-bg-primary);
color: var(--color-accent);
}
.sort-select {
height: 42px;
padding: 0 14px;
border: 0;
outline: none;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
color: var(--color-text-primary);
}
.state-panel {
min-height: 300px;
display: grid;
place-items: center;
align-content: center;
gap: 12px;
color: var(--color-text-muted);
}
.state-panel svg {
width: 38px;
height: 38px;
}
.local-content {
margin-top: 18px;
}
.song-list,
.group-transition-list {
position: relative;
}
.list-shift-move,
.list-shift-enter-active,
.list-shift-leave-active {
transition:
opacity 180ms cubic-bezier(0.2, 0, 0, 1),
transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.list-shift-enter-from,
.list-shift-leave-to {
opacity: 0;
transform: translateY(6px);
}
.list-shift-leave-active {
position: absolute;
width: 100%;
}
.list-header {
height: 36px;
display: grid;
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px 42px;
align-items: center;
gap: 16px;
color: var(--color-text-muted);
font-size: 12px;
border-bottom: 1px solid var(--color-border);
}
.group-block {
margin-bottom: 10px;
}
.group-header {
width: 100%;
min-height: 54px;
justify-content: space-between;
padding: 0 14px;
border-radius: 18px;
color: var(--color-text-primary);
}
.group-header:hover {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.group-header strong,
.group-header span {
display: block;
text-align: left;
}
.group-header span {
margin-top: 4px;
color: var(--color-text-muted);
font-size: 12px;
}
.group-list {
padding-left: 12px;
}
.pagination {
gap: 8px;
margin-top: 24px; margin-top: 24px;
background-color: var(--color-accent);
color: var(--color-bg-primary);
padding: 12px 24px;
border-radius: var(--radius-full);
font-weight: 600;
transition: opacity 0.2s;
} }
.action-btn:hover { .page-btn {
opacity: 0.9; min-width: 34px;
height: 34px;
padding: 0 10px;
border-radius: 12px;
color: var(--color-text-secondary);
}
.page-btn:hover:not(:disabled),
.page-btn.active {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 920px) {
.local-hero {
display: block;
}
.hero-actions {
margin-top: 22px;
}
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.list-shift-move,
.list-shift-enter-active,
.list-shift-leave-active {
transition: none;
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,393 @@
<template>
<div class="playlist-square-view">
<div class="content-wrapper">
<section class="toolbar">
<div>
<h1>歌单广场</h1>
<p>浏览其他用户公开的歌单</p>
</div>
<div class="toolbar-controls">
<label class="search-box">
<Icon icon="lucide:search" />
<input v-model="query" placeholder="搜索歌单、简介或作者" />
</label>
<div class="sort-tabs">
<button :class="{ active: sort === 'visit' }" @click="setSort('visit')">
<Icon icon="lucide:trending-up" />
访问量
</button>
<button :class="{ active: sort === 'total' }" @click="setSort('total')">
<Icon icon="lucide:list-music" />
歌曲数
</button>
<button :class="{ active: sort === 'name' }" @click="setSort('name')">
<Icon icon="lucide:arrow-down-a-z" />
名称
</button>
</div>
</div>
</section>
<section class="result-section">
<div class="section-meta">
<span>{{ loading ? '加载中' : `${total} 个公开歌单` }}</span>
<span>{{ sortLabel }}排序 · {{ page }} / {{ totalPages }} </span>
</div>
<div v-if="loading" class="playlist-grid">
<div v-for="i in pageSize" :key="i" class="playlist-card skeleton"></div>
</div>
<div v-else-if="playlists.length === 0" class="empty-state">
<Icon icon="lucide:library" />
<span>暂无匹配的公开歌单</span>
</div>
<div v-else class="playlist-grid">
<button
v-for="playlist in playlists"
:key="playlist.id"
class="playlist-card"
@click="openPlaylist(playlist.id)"
>
<div class="cover">
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
<Icon v-else icon="lucide:cloud" />
</div>
<div class="playlist-name">{{ playlist.info.name || '云歌单' }}</div>
<div class="playlist-desc">{{ playlist.info.desc || '没有简介' }}</div>
<div class="playlist-meta">
<span>
<Icon icon="lucide:eye" />
{{ Number(playlist.info.visit_count || playlist.info.play_count || 0) || 0 }}
</span>
<span>
<Icon icon="lucide:music" />
{{ playlist.total || 0 }}
</span>
</div>
</button>
</div>
<div v-if="!loading && totalPages > 1" class="pagination">
<button class="page-btn" :disabled="page <= 1" @click="changePage(page - 1)">
<Icon icon="lucide:chevron-left" />
上一页
</button>
<span>{{ page }} / {{ totalPages }}</span>
<button class="page-btn" :disabled="page >= totalPages" @click="changePage(page + 1)">
下一页
<Icon icon="lucide:chevron-right" />
</button>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import { usePlaylistsStore, type AppPlaylist } from '../stores/playlists'
const router = useRouter()
const playlistStore = usePlaylistsStore()
const query = ref('')
const sort = ref<'visit' | 'total' | 'name'>('visit')
const playlists = ref<AppPlaylist[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 12
const loading = ref(false)
let timer: number | null = null
let requestId = 0
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const sortLabel = computed(() => {
if (sort.value === 'total') return '歌曲数'
if (sort.value === 'name') return '名称'
return '访问量'
})
const loadPlaylists = async () => {
const currentRequest = ++requestId
loading.value = true
try {
const result = await playlistStore.publicList(query.value, sort.value, page.value, pageSize)
if (currentRequest !== requestId) return
playlists.value = result.items || []
total.value = result.total || playlists.value.length
if (page.value > totalPages.value) page.value = totalPages.value
} catch (err: any) {
if (currentRequest !== requestId) return
playlists.value = []
total.value = 0
ElMessage.error(err?.message || '歌单广场加载失败')
} finally {
if (currentRequest === requestId) loading.value = false
}
}
const scheduleLoad = (delay = 600) => {
if (timer != null) window.clearTimeout(timer)
timer = window.setTimeout(loadPlaylists, delay)
}
watch(query, () => {
page.value = 1
scheduleLoad(600)
}, { immediate: true })
watch(page, () => scheduleLoad(120))
const setSort = (nextSort: 'visit' | 'total' | 'name') => {
if (sort.value === nextSort) return
sort.value = nextSort
page.value = 1
scheduleLoad(120)
}
const changePage = (nextPage: number) => {
page.value = Math.max(1, Math.min(totalPages.value, nextPage))
}
onBeforeUnmount(() => {
if (timer != null) window.clearTimeout(timer)
})
const openPlaylist = (id: string) => {
router.push({ name: 'PlaylistDetail', params: { scope: 'cloud', id } })
}
</script>
<style scoped>
.playlist-square-view {
min-height: 100%;
background: transparent;
}
.content-wrapper {
box-sizing: border-box;
max-width: 1180px;
margin: 0 auto;
padding: 30px 32px 132px;
}
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
align-items: end;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--color-border);
}
h1 {
font-size: 34px;
color: var(--color-text-primary);
}
p,
.section-meta,
.playlist-desc,
.playlist-meta {
color: var(--color-text-muted);
}
.toolbar p {
margin-top: 8px;
}
.toolbar-controls {
display: grid;
gap: 12px;
}
.search-box {
min-height: 44px;
padding: 0 14px;
border-radius: 18px;
display: flex;
align-items: center;
gap: 10px;
background: var(--color-bg-secondary);
color: var(--color-text-muted);
}
.search-box input {
min-width: 0;
flex: 1;
border: 0;
outline: none;
background: transparent;
color: var(--color-text-primary);
font-size: 14px;
}
.sort-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.sort-tabs button,
.page-btn {
min-height: 36px;
padding: 0 13px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.sort-tabs button.active {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
}
.sort-tabs svg,
.playlist-meta svg,
.page-btn svg {
width: 15px;
height: 15px;
}
.result-section {
margin-top: 24px;
}
.section-meta,
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 13px;
}
.section-meta {
margin-bottom: 16px;
}
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 18px;
}
.playlist-card {
min-width: 0;
padding: 0;
text-align: left;
border-radius: 18px;
transition: transform 160ms ease;
}
.playlist-card:hover {
transform: translateY(-1px);
}
.cover {
width: 100%;
aspect-ratio: 1;
border-radius: 18px;
display: grid;
place-items: center;
overflow: hidden;
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-bg-secondary));
color: var(--color-accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover svg {
width: 42px;
height: 42px;
}
.playlist-name {
margin-top: 10px;
font-size: 14px;
font-weight: 760;
color: var(--color-text-primary);
}
.playlist-desc,
.playlist-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.playlist-desc {
margin-top: 4px;
font-size: 12px;
}
.playlist-meta {
margin-top: 8px;
display: flex;
gap: 12px;
font-size: 12px;
}
.playlist-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pagination {
max-width: 360px;
margin: 24px auto 0;
padding: 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
}
.page-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.empty-state {
min-height: 260px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
color: var(--color-text-muted);
}
.empty-state svg {
width: 46px;
height: 46px;
color: var(--color-accent);
}
.skeleton {
height: 234px;
background: linear-gradient(90deg, var(--color-bg-secondary), var(--color-bg-tertiary), var(--color-bg-secondary));
background-size: 220% 100%;
animation: shimmer 1.2s ease infinite;
}
@keyframes shimmer {
to { background-position: -220% 0; }
}
@media (max-width: 900px) {
.toolbar {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -66,24 +66,14 @@
</div> </div>
<div class="song-list"> <div class="song-list">
<div <SongTile
class="song-item"
v-for="(song, i) in songs" v-for="(song, i) in songs"
:key="song.id" :key="`${song.source}:${song.id}:${i}`"
@click="handlePlaySong(i)" :song="song"
> :display-index="(currentPage - 1) * limit + i + 1"
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div> :highlight="highlight"
<div class="song-cover"> @play="handlePlaySong(i)"
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" /> />
<div v-else class="cover-placeholder"></div>
</div>
<div class="song-info">
<h4 class="song-title" v-html="highlight(song.name)"></h4>
<p class="song-artist" v-html="highlight(song.artist)"></p>
</div>
<div class="song-album" v-html="highlight(song.albumName || '-')"></div>
<div class="song-duration">{{ song.duration }}</div>
</div>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@@ -130,6 +120,7 @@ import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { transformSearchSong } from '../utils/songUtils'; import { transformSearchSong } from '../utils/songUtils';
import SongTile from '../components/SongTile.vue';
import type { Song } from '../types/song'; import type { Song } from '../types/song';
const route = useRoute(); const route = useRoute();
@@ -345,11 +336,12 @@ onBeforeUnmount(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
scroll-padding-bottom: 148px;
} }
.content-wrapper { .content-wrapper {
box-sizing: border-box; box-sizing: border-box;
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */ padding: 20px 30px 148px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
width: 100%; width: 100%;
/* Removed max-width to allow full width usage as requested */ /* Removed max-width to allow full width usage as requested */
/* margin: 0 auto; */ /* margin: 0 auto; */
@@ -383,7 +375,7 @@ onBeforeUnmount(() => {
transform: translateY(-50%); transform: translateY(-50%);
width: 4px; width: 4px;
height: 70%; height: 70%;
background-color: var(--color-accent); background: var(--color-accent-gradient);
border-radius: 4px; border-radius: 4px;
} }
@@ -561,7 +553,7 @@ onBeforeUnmount(() => {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: var(--color-accent); background: var(--color-accent-gradient);
cursor: pointer; cursor: pointer;
box-shadow: 0 0 4px rgba(0,0,0,0.2); box-shadow: 0 0 4px rgba(0,0,0,0.2);
} }
@@ -571,7 +563,7 @@ onBeforeUnmount(() => {
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
background: var(--color-accent); background: var(--color-accent-gradient);
border-radius: 2px; border-radius: 2px;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
@@ -797,7 +789,7 @@ onBeforeUnmount(() => {
} }
.pagination-btn.active { .pagination-btn.active {
background: var(--color-accent); background: var(--color-accent-gradient);
color: #fff; /* Ensure readable text on accent */ color: #fff; /* Ensure readable text on accent */
border-color: var(--color-accent); border-color: var(--color-accent);
font-weight: bold; font-weight: bold;
@@ -851,7 +843,7 @@ onBeforeUnmount(() => {
.retry-btn { .retry-btn {
margin-top: 8px; margin-top: 8px;
padding: 8px 24px; padding: 8px 24px;
background: var(--color-accent); background: var(--color-accent-gradient);
color: white; /* Ensure text is readable on accent color */ color: white; /* Ensure text is readable on accent color */
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 14px; font-size: 14px;

View File

@@ -0,0 +1,625 @@
<template>
<div class="user-profile-view" :class="{ 'is-loading': loading }">
<section class="profile-hero">
<div v-if="loading" class="hero-loading">
<Icon icon="lucide:loader-2" class="spin" />
</div>
<div class="avatar-wrap">
<img v-if="profile?.avatar" :src="profile.avatar" alt="" />
<Icon v-else icon="lucide:user" />
</div>
<div class="profile-copy">
<div class="eyebrow">{{ isOwnProfile ? 'MY PROFILE' : 'USER PROFILE' }}</div>
<h1>{{ displayName }}</h1>
<p class="user-id">@{{ profile?.username || profile?.id || routeUserId }}</p>
<p class="intro">{{ profile?.intro || '这个人还没有写简介。' }}</p>
<div class="profile-meta">
<span v-if="profile?.region">{{ profile.region }}</span>
<span v-if="profile?.gender">{{ profile.gender }}</span>
<span v-if="profile?.birthday && profile.birthday !== '????-??-??'">{{ profile.birthday }}</span>
</div>
</div>
<button v-if="isOwnProfile" class="soft-btn" @click="openEditDialog">
<Icon icon="lucide:pencil" />
编辑资料
</button>
</section>
<section class="profile-grid">
<div class="profile-panel">
<div class="panel-head">
<div>
<h2>{{ playlistPanelTitle }}</h2>
<p>{{ playlists.length }} 个歌单</p>
</div>
<Icon icon="lucide:library" />
</div>
<div v-if="loading" class="panel-empty">
<Icon icon="lucide:loader-2" class="spin" />
<span>加载中...</span>
</div>
<div v-else-if="playlists.length === 0" class="panel-empty">{{ playlistEmptyText }}</div>
<template v-else>
<router-link
v-for="playlist in playlists"
:key="playlist.id"
class="playlist-row"
:to="{ name: 'PlaylistDetail', params: { scope: 'cloud', id: playlist.id } }"
>
<div class="mini-cover">
<img v-if="playlist.img" :src="playlist.img" alt="" />
<Icon v-else icon="lucide:cloud" />
</div>
<div>
<strong>{{ playlist.name || '云歌单' }}</strong>
<span>{{ playlist.desc || '没有简介' }}</span>
<small>访问 {{ Number(playlist.visit_count || playlist.play_count || 0) || 0 }} </small>
</div>
</router-link>
</template>
</div>
<div
class="profile-panel clickable-panel"
:class="{ disabled: loading || favSongs.length === 0 }"
@click="openLikedPlaylist"
>
<div class="panel-head">
<div>
<h2>喜欢的歌</h2>
<p>{{ favSongs.length }} 首歌曲</p>
</div>
<Icon icon="lucide:heart" />
</div>
<div v-if="loading" class="panel-empty">
<Icon icon="lucide:loader-2" class="spin" />
<span>加载中...</span>
</div>
<div v-else-if="favSongs.length === 0" class="panel-empty">喜欢列表暂不可见或为空</div>
<template v-else>
<div
v-for="song in favSongs.slice(0, 12)"
:key="`${song.source}:${song.id}`"
class="song-row"
>
<div class="song-thumb">
<img v-if="song.picUrl || song.pic" :src="song.picUrl || song.pic" alt="" />
<Icon v-else icon="lucide:music" />
</div>
<div>
<strong>{{ song.name || '未知歌曲' }}</strong>
<span>{{ song.artist || song.artists || '未知歌手' }}</span>
</div>
</div>
<button v-if="favSongs.length > 12" class="show-more-btn" @click.stop="openLikedPlaylist">
显示更多
</button>
</template>
</div>
</section>
<Transition name="fade">
<div v-if="showEditDialog" class="dialog-backdrop" @click.self="showEditDialog = false">
<div class="edit-dialog">
<div class="dialog-title">编辑资料</div>
<input v-model="draft.nickname" class="text-input" placeholder="昵称" />
<textarea v-model="draft.intro" class="text-area" placeholder="简介"></textarea>
<div class="field-grid">
<input v-model="draft.gender" class="text-input" placeholder="性别" />
<input v-model="draft.region" class="text-input" placeholder="地区" />
<input v-model="draft.birthday" class="text-input" placeholder="生日" />
<input v-model="draft.avatar" class="text-input" placeholder="头像 URL" />
</div>
<div class="dialog-actions">
<button class="ghost-btn" @click="showEditDialog = false">取消</button>
<button class="primary-btn compact" :disabled="savingProfile" @click="saveProfile">
<Icon v-if="savingProfile" icon="lucide:loader-2" class="spin" />
保存
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Icon } from '@iconify/vue'
import { ElMessage } from 'element-plus'
import { useAuthStore, type UserInfo } from '../stores/auth'
type PublicPlaylist = {
id: string
name?: string
desc?: string
img?: string
total?: number
play_count?: string
visit_count?: number
}
type PublicSong = {
id: string
name?: string
artist?: string
artists?: string
source: string
pic?: string
picUrl?: string
interval?: string
duration?: string
albumName?: string | null
albumId?: string | null
quality?: string
qualities?: Record<string, string>
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const profile = ref<UserInfo | null>(null)
const playlists = ref<PublicPlaylist[]>([])
const favSongs = ref<PublicSong[]>([])
const loading = ref(false)
const showEditDialog = ref(false)
const savingProfile = ref(false)
const draft = reactive({
nickname: '',
intro: '',
gender: '',
region: '',
birthday: '',
avatar: '',
})
const routeUserId = computed(() => String(route.params.id || authStore.state.userInfo?.id || ''))
const isOwnProfile = computed(() => Boolean(authStore.state.userInfo?.id && authStore.state.userInfo.id === routeUserId.value))
const displayName = computed(() => profile.value?.nickname || profile.value?.username || '用户')
const playlistPanelTitle = computed(() => isOwnProfile.value ? '我的歌单' : '公开歌单')
const playlistEmptyText = computed(() => isOwnProfile.value ? '还没有创建歌单' : '暂无可查看的公开歌单')
const loadProfile = async () => {
if (!routeUserId.value) {
router.replace('/')
return
}
loading.value = true
try {
profile.value = await window.electronAPI.user.getProfile(routeUserId.value)
const [playlistResult, favResult] = await Promise.allSettled([
window.electronAPI.user.getPlaylists(routeUserId.value),
window.electronAPI.user.getFavSongs(routeUserId.value),
])
playlists.value = playlistResult.status === 'fulfilled' ? playlistResult.value : []
favSongs.value = favResult.status === 'fulfilled' ? favResult.value : []
if (route.query.edit === '1' && isOwnProfile.value) openEditDialog()
} catch (err: any) {
ElMessage.error(err?.message || '用户资料加载失败')
profile.value = null
} finally {
loading.value = false
}
}
const openEditDialog = () => {
if (!isOwnProfile.value || !profile.value) return
draft.nickname = profile.value.nickname || ''
draft.intro = profile.value.intro || ''
draft.gender = profile.value.gender || ''
draft.region = profile.value.region || ''
draft.birthday = profile.value.birthday || ''
draft.avatar = profile.value.avatar || ''
showEditDialog.value = true
}
const saveProfile = async () => {
savingProfile.value = true
try {
const updated = await window.electronAPI.user.updateProfile({
nickname: draft.nickname,
intro: draft.intro,
gender: draft.gender,
region: draft.region,
birthday: draft.birthday,
avatar: draft.avatar,
})
profile.value = updated
authStore.applyUserInfo(updated)
showEditDialog.value = false
ElMessage.success('资料已更新')
} catch (err: any) {
ElMessage.error(err?.message || '资料更新失败')
} finally {
savingProfile.value = false
}
}
const openLikedPlaylist = () => {
if (loading.value || favSongs.value.length === 0 || !routeUserId.value) return
router.push({ name: 'UserLikedPlaylist', params: { id: routeUserId.value } })
}
watch(() => [route.params.id, route.query.edit, authStore.state.userInfo?.id], loadProfile, { immediate: true })
</script>
<style scoped>
.user-profile-view {
min-height: 100%;
padding: 30px 32px 148px;
box-sizing: border-box;
}
.profile-hero {
min-height: 228px;
padding: 30px;
display: flex;
align-items: flex-end;
gap: 24px;
border-radius: 30px;
background:
radial-gradient(circle at 8% 0%, color-mix(in srgb, var(--color-accent) 20%, transparent), transparent 36%),
color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 8%, transparent);
position: relative;
animation: profile-enter 260ms ease both;
}
.hero-loading {
position: absolute;
top: 22px;
right: 22px;
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
}
.avatar-wrap {
width: 116px;
height: 116px;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
background: var(--color-accent-soft);
color: var(--color-accent);
flex: 0 0 auto;
}
.avatar-wrap img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-wrap svg {
width: 48px;
height: 48px;
}
.profile-copy {
min-width: 0;
flex: 1;
}
.eyebrow {
color: var(--color-text-muted);
font-size: 12px;
font-weight: 760;
}
h1 {
margin-top: 8px;
font-size: 42px;
line-height: 1.08;
color: var(--color-text-primary);
}
.user-id,
.intro,
.profile-meta,
.panel-head p,
.playlist-row span,
.playlist-row small,
.song-row span,
.panel-empty {
color: var(--color-text-muted);
}
.user-id {
margin-top: 5px;
}
.intro {
max-width: 620px;
margin-top: 12px;
color: var(--color-text-secondary);
}
.profile-meta {
margin-top: 12px;
display: flex;
gap: 10px;
font-size: 13px;
}
.profile-grid {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.profile-panel {
min-height: 320px;
padding: 22px;
border-radius: 26px;
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
animation: profile-enter 280ms ease both;
}
.profile-panel:nth-child(2) {
animation-delay: 45ms;
}
.clickable-panel {
cursor: pointer;
transition: background-color 160ms ease, transform 160ms ease;
}
.clickable-panel:hover {
background: color-mix(in srgb, var(--color-accent) 7%, var(--color-bg-secondary));
transform: translateY(-1px);
}
.clickable-panel.disabled {
cursor: default;
transform: none;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-head h2 {
font-size: 20px;
}
.panel-head svg {
color: var(--color-accent);
}
.playlist-row,
.song-row {
min-height: 58px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 18px;
text-decoration: none;
color: var(--color-text-primary);
transition: background-color 160ms ease;
}
.song-row {
width: 100%;
text-align: left;
}
.playlist-row:hover,
.song-row:hover {
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.mini-cover,
.song-thumb {
width: 42px;
height: 42px;
border-radius: 14px;
overflow: hidden;
flex: 0 0 auto;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
}
.song-thumb {
border-radius: 50%;
}
.mini-cover img,
.song-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-row div:last-child,
.song-row div:last-child {
min-width: 0;
display: grid;
gap: 3px;
}
.playlist-row strong,
.playlist-row span,
.playlist-row small,
.song-row strong,
.song-row span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.playlist-row small {
font-size: 12px;
}
.panel-empty {
min-height: 210px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
text-align: center;
}
.show-more-btn {
width: 100%;
min-height: 38px;
margin-top: 8px;
border-radius: 999px;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.show-more-btn:hover {
background: color-mix(in srgb, var(--color-accent) 13%, transparent);
}
.soft-btn,
.primary-btn,
.ghost-btn {
min-height: 40px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.soft-btn,
.ghost-btn {
padding: 0 16px;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
color: var(--color-text-secondary);
}
.soft-btn:hover,
.ghost-btn:hover {
color: var(--color-text-primary);
}
.primary-btn {
padding: 0 18px;
background: var(--color-accent-gradient);
color: white;
}
.primary-btn.compact {
min-height: 38px;
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
}
.edit-dialog {
width: min(460px, calc(100vw - 32px));
padding: 22px;
border-radius: 24px;
background: var(--color-bg-primary);
box-shadow: var(--shadow-elevated);
}
.dialog-title {
font-size: 18px;
font-weight: 750;
margin-bottom: 16px;
}
.text-input,
.text-area {
width: 100%;
border: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
background: var(--color-bg-secondary);
border-radius: 16px;
outline: none;
padding: 12px 14px;
margin-bottom: 10px;
color: var(--color-text-primary);
}
.text-area {
min-height: 92px;
resize: none;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 10px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes profile-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 180ms ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 900px) {
.profile-hero {
display: block;
}
.avatar-wrap {
margin-bottom: 18px;
}
.soft-btn {
margin-top: 18px;
}
.profile-grid {
grid-template-columns: 1fr;
}
}
</style>