diff --git a/native/taglib_reader/build.ps1 b/native/taglib_reader/build.ps1 new file mode 100644 index 0000000..7e32353 --- /dev/null +++ b/native/taglib_reader/build.ps1 @@ -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" diff --git a/native/taglib_reader/build/taglib_reader_cli.exe b/native/taglib_reader/build/taglib_reader_cli.exe new file mode 100644 index 0000000..5050b94 Binary files /dev/null and b/native/taglib_reader/build/taglib_reader_cli.exe differ diff --git a/native/taglib_reader/taglib_reader_cli.cpp b/native/taglib_reader/taglib_reader_cli.cpp new file mode 100644 index 0000000..6af180b --- /dev/null +++ b/native/taglib_reader/taglib_reader_cli.cpp @@ -0,0 +1,403 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(value.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) return ""; + std::string result(size, '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(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(value.size()), nullptr, 0); + if (size <= 0) return L""; + std::wstring result(size, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(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(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(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(file)) { + if (flac->audioProperties()) { + std::ostringstream stream; + stream << (sampleRate / 1000.0) << "kHz " << flac->audioProperties()->bitsPerSample() << "bit"; + return stream.str(); + } + } + + if (auto *wav = dynamic_cast(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(file)) { + if (auto *id3 = mp3->ID3v2Tag()) { + const auto frames = id3->frameList("APIC"); + if (!frames.isEmpty()) { + if (auto *frame = dynamic_cast(frames.front())) return frame->picture(); + } + } + } + + if (auto *wav = dynamic_cast(file)) { + if (auto *id3 = wav->ID3v2Tag()) { + const auto frames = id3->frameList("APIC"); + if (!frames.isEmpty()) { + if (auto *frame = dynamic_cast(frames.front())) return frame->picture(); + } + } + } + + if (auto *flac = dynamic_cast(file)) { + const auto pictures = flac->pictureList(); + if (!pictures.isEmpty()) return pictures.front()->data(); + } + + if (auto *mp4 = dynamic_cast(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(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 &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(file)) { + if (auto *id3 = mp3->ID3v2Tag()) { + const auto frames = id3->frameList("USLT"); + for (auto *rawFrame : frames) { + if (auto *frame = dynamic_cast(rawFrame)) { + const auto text = Trim(ToUtf8(frame->text())); + if (!text.empty()) return text; + } + } + } + } + + if (auto *wav = dynamic_cast(file)) { + if (auto *id3 = wav->ID3v2Tag()) { + const auto frames = id3->frameList("USLT"); + for (auto *rawFrame : frames) { + if (auto *frame = dynamic_cast(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(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 CollectAudioFiles(const std::vector &roots) { + std::vector 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 | scan [--artwork-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 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; + } +} diff --git a/package-lock.json b/package-lock.json index 03418ba..aafd19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,13 @@ "@pixi/filter-bulge-pinch": "^5.1.1", "@pixi/filter-color-matrix": "^7.4.3", "@pixi/sprite": "^7.4.3", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue-jsx": "^5.1.5", "element-plus": "^2.13.7", "jss": "^10.10.0", "jss-preset-default": "^10.10.0", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "url": "^0.11.4", "vite-plugin-wasm": "^3.6.0", "vue": "^3.5.33", @@ -3762,6 +3764,15 @@ "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": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", @@ -4391,7 +4402,6 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4401,7 +4411,6 @@ "version": "4.3.0", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5300,6 +5309,15 @@ "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": { "version": "1.0.30001768", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", @@ -5461,7 +5479,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5474,7 +5491,6 @@ "version": "1.1.4", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "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": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6057,6 +6082,12 @@ "dev": true, "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": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-3.3.0.tgz", @@ -7056,7 +7087,6 @@ "version": "8.0.0", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -7578,7 +7608,6 @@ "version": "2.0.5", "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -8157,7 +8186,6 @@ "version": "3.0.0", "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9698,6 +9726,15 @@ "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": { "version": "1.0.1", "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", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9913,6 +9949,15 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10088,6 +10133,141 @@ "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": { "version": "6.14.1", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz", @@ -10224,12 +10404,17 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "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": { "version": "1.22.11", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", @@ -11106,6 +11291,12 @@ "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": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11437,7 +11628,6 @@ "version": "4.2.3", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -11468,7 +11658,6 @@ "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13097,6 +13286,12 @@ "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": { "version": "1.1.20", "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index 22d6fc1..ceed200 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@pixi/filter-bulge-pinch": "^5.1.1", "@pixi/filter-color-matrix": "^7.4.3", "@pixi/sprite": "^7.4.3", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue-jsx": "^5.1.5", "element-plus": "^2.13.7", "jss": "^10.10.0", "jss-preset-default": "^10.10.0", "pinia": "^3.0.4", + "qrcode": "^1.5.4", "url": "^0.11.4", "vite-plugin-wasm": "^3.6.0", "vue": "^3.5.33", @@ -72,6 +74,10 @@ { "from": "core/libfftw3f-3.dll", "to": "core/libfftw3f-3.dll" + }, + { + "from": "native/taglib_reader/build/taglib_reader_cli.exe", + "to": "native/taglib_reader_cli.exe" } ] } diff --git a/src/main/authStore.ts b/src/main/authStore.ts new file mode 100644 index 0000000..30771d3 --- /dev/null +++ b/src/main/authStore.ts @@ -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(pathname: string, body: any): Promise { + 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 +} + +export async function createQrLoginSession(): Promise { + const deviceName = `${app.getName() || 'QZMusic'} on ${os.hostname() || process.platform}` + const payload = await authQrFetch>('/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 { + const payload = await authQrFetch('/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 { + 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 { + 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 { + 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 { + 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 { + const query = new URLSearchParams(params) + return `wss://interface.qz.folltoshe.com/ws?${query.toString()}` +} + +export async function sendPcHeartbeat(duration: number, timestamp = Date.now()): Promise { + return qzFetch('/heartbeat/pc', { + method: 'POST', + body: JSON.stringify({ duration, timestamp }), + }) +} + +export async function getListenTime(detail = 1, userId?: string): Promise { + 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 { + 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 { + 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 { + if (!userId) throw new Error('Missing user id') + return qzFetch(`/user/${encodeURIComponent(userId)}/info`) +} + +export async function getUserPublicPlaylists(userId: string): Promise { + 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 { + 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): Promise { + 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), + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index fff436b..676bd20 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,53 @@ import { QzpController } from './qzpController' import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow, refreshCacheDir } from './proxyServer' import { PluginSystem } from './pluginSystem' 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 const require = createRequire(import.meta.url) @@ -26,6 +73,65 @@ function notifyPluginsChanged(action: 'installed' | 'updated' | 'uninstalled', p 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 窗口逻辑 === 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-close', () => win?.close()) 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 --- ipcMain.handle('qzplayer-command', async (_, command: any[]) => { @@ -102,6 +218,16 @@ ipcMain.handle('plugin:getAll', () => { 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) => { const success = PluginSystem.uninstallPlugin(id) if (success) notifyPluginsChanged('uninstalled', id) @@ -127,6 +253,159 @@ ipcMain.handle('plugin:install', async () => { return result }) +// Auth IPC Handlers +ipcMain.handle('auth:getState', () => loadAuthState()) +ipcMain.handle('auth:getAccessToken', () => getValidAccessToken()) +ipcMain.handle('listenTogether:getWsUrl', async (_event, params: Record) => { + 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 ipcMain.handle('cache:getInfo', () => { const settings = loadSettings(); @@ -165,6 +444,36 @@ ipcMain.handle('dialog:openDirectory', async () => { 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) => { try { const settings = loadSettings() @@ -299,6 +608,9 @@ app.whenReady().then(() => { Menu.setApplicationMenu(null) createWindow() + const callbackUrl = process.argv.find((arg) => arg.startsWith('qzmusic://auth_result')) + if (callbackUrl) handleAuthCallback(callbackUrl) + // Start Proxy Server startProxyServer() diff --git a/src/main/localMusicStore.ts b/src/main/localMusicStore.ts new file mode 100644 index 0000000..61bb056 --- /dev/null +++ b/src/main/localMusicStore.ts @@ -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 { + 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(), + }) +} diff --git a/src/main/playlistStore.ts b/src/main/playlistStore.ts new file mode 100644 index 0000000..d0b493f --- /dev/null +++ b/src/main/playlistStore.ts @@ -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 + types?: Record +} + +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 & { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/pluginSystem.ts b/src/main/pluginSystem.ts index 474370a..6835075 100644 --- a/src/main/pluginSystem.ts +++ b/src/main/pluginSystem.ts @@ -30,10 +30,15 @@ type PluginModule = Record & { info?: PluginInfo['info'] getUrl?: (...args: any[]) => any getLyric?: (...args: any[]) => any + getPlaylist?: (...args: any[]) => any + getPlayList?: (...args: any[]) => any + getAlbum?: (...args: any[]) => any musicSearch?: { search?: (...args: any[]) => any } | ((...args: any[]) => any) search?: (...args: any[]) => any + songList?: Record + album?: Record } 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 { + 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 { private readonly pluginId: string private plugin: PluginModule | null = null @@ -210,6 +289,12 @@ export class PluginSystem { if (method === 'getUrl') { 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 target = plugin[method] @@ -290,6 +375,29 @@ export class PluginSystem { } } + async getPlaylist(id: string, page = 1, limit = 100): Promise { + 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 { + 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[] { const pluginsPath = getPluginsPath() if (!fs.existsSync(pluginsPath)) return [] diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index d15cb74..b41b7a6 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -9,13 +9,18 @@ export interface AppSettings { // Appearance theme: 'dark' | 'light'; accentColor: string; + // Playlist + playlistPagingMode: 'infinite' | 'pagination'; + openPlayerOnSongClick: boolean; } const DEFAULT_SETTINGS: AppSettings = { persistCache: true, cachePath: path.join(app.getPath('userData'), 'cache'), // Default theme: 'light', - accentColor: '#ec4141', // Default red + accentColor: '#8289d3', + playlistPagingMode: 'infinite', + openPlayerOnSongClick: false, }; let settingsCache: AppSettings | null = null; diff --git a/src/preload/index.ts b/src/preload/index.ts index ea77ba6..1cb56dc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,11 +1,25 @@ 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', { // 窗口控制 minimizeWindow: () => ipcRenderer.send('window-minimize'), maximizeWindow: () => ipcRenderer.send('window-maximize'), closeWindow: () => ipcRenderer.send('window-close'), isMaximized: () => ipcRenderer.invoke('window-is-maximized'), + setTaskbarProgress: (progress: number, mode: 'normal' | 'paused' = 'normal') => ipcRenderer.invoke('window:setProgressBar', progress, mode), // qzplayer Control qzplayer: { @@ -24,6 +38,8 @@ contextBridge.exposeInMainWorld('electronAPI', { 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]), 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'), uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id), 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) => 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 getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'), setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist), @@ -44,6 +121,15 @@ contextBridge.exposeInMainWorld('electronAPI', { clearCache: () => ipcRenderer.invoke('cache:clear'), changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath), 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: { diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 33f18a6..baca889 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -1,30 +1,76 @@ \ No newline at end of file + diff --git a/src/renderer/src/assets/long_width_bg.png b/src/renderer/src/assets/long_width_bg.png new file mode 100644 index 0000000..ff39be3 Binary files /dev/null and b/src/renderer/src/assets/long_width_bg.png differ diff --git a/src/renderer/src/components/FullScreenPlayer.vue b/src/renderer/src/components/FullScreenPlayer.vue index 88e6a72..3c2339f 100644 --- a/src/renderer/src/components/FullScreenPlayer.vue +++ b/src/renderer/src/components/FullScreenPlayer.vue @@ -16,7 +16,7 @@ 播放队列 {{ playerStore.playlist.length }} 首 -
-
-
- - - - - - {{ index + 1 }} -
- -
-
-
{{ song.name }}
-
{{ song.artist }}
-
-
{{ song.duration }}
-
-
+ @@ -181,6 +157,7 @@ import MusicInfo from './player/MusicInfo.vue'; import MediaButton from './player/MediaButton.vue'; import VolumeControl from './player/VolumeControl.vue'; import ToggleIconButton from './player/ToggleIconButton.vue'; +import PlayerQueueList from './player/PlayerQueueList.vue'; // Icons import IconRewind from '@assets/icon_rewind.svg'; @@ -357,13 +334,6 @@ const togglePlaylistPanel = () => { showPlaylistPanel.value = opening; }; -const playFromPlaylist = (index: number) => { - const song = playerStore.playlist[index]; - if (song) { - playerStore.playSong(song); - } -}; - // watch(()=>playerStore.currentTime,(t)=>{ // console.log(toRaw(t)) // }) @@ -373,18 +343,26 @@ const playFromPlaylist = (index: number) => { .fullscreen-player { --height: calc(100vh); position: fixed; - top: var(--height); + top: 0; left: 0; width: 100%; height: var(--height); 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 */ overflow: hidden; + pointer-events: none; } .fullscreen-player.active { - top: 0; + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; } .background-container { @@ -509,6 +487,10 @@ const playFromPlaylist = (index: number) => { position: relative; } +.cover.shared-cover { + view-transition-name: now-playing-cover; +} + .controls { grid-area: music-info / info-side; will-change: transform; diff --git a/src/renderer/src/components/LoginDialog.vue b/src/renderer/src/components/LoginDialog.vue new file mode 100644 index 0000000..c1baae6 --- /dev/null +++ b/src/renderer/src/components/LoginDialog.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/src/renderer/src/components/Settings.vue b/src/renderer/src/components/Settings.vue index efcdac1..d813447 100644 --- a/src/renderer/src/components/Settings.vue +++ b/src/renderer/src/components/Settings.vue @@ -80,6 +80,34 @@ +
+

隐私设置

+
+
+
允许他人查看我的喜欢和歌单
+
关闭后,公开歌单和喜欢的歌曲都只对自己可见
+
+
+ +
+
+
+
+
允许他人查看我的个人信息
+
关闭后,地区、性别和生日只对自己可见
+
+
+ +
+
+
+
@@ -167,7 +195,7 @@ :key="color.value" class="color-swatch" :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" @click="setAccentColor(color.value)" > @@ -202,6 +230,44 @@
+
+
+
歌单加载方式
+
选择歌单和专辑页面的歌曲加载方式
+
+
+
+ + +
+
+
+ +
+
+
点击歌曲后打开播放页
+
从搜索、歌单、推荐里点击歌曲播放时,自动进入全屏播放页
+
+
+ +
+
+

音质、淡入淡出等设置即将推出

@@ -209,7 +275,17 @@
-
+
+

快捷键

+
+
+
+
{{ item.name }}
+
{{ item.desc }}
+
+ {{ item.key }} +
+

快捷键

@@ -260,6 +336,7 @@ defineEmits(['close']); const categories = [ { id: 'storage', name: '存储', icon: 'lucide:hard-drive' }, + { id: 'privacy', name: '隐私', icon: 'lucide:shield' }, { id: 'plugins', name: '插件', icon: 'lucide:blocks' }, { id: 'appearance', name: '外观', icon: 'lucide:palette' }, { id: 'playback', name: '播放', icon: 'lucide:headphones' }, @@ -268,6 +345,11 @@ const categories = [ ]; const accentColors = [ + { + name: '默认蓝紫', + value: '#8289d3', + gradient: 'linear-gradient(135deg, #b0baeb 0%, #b1bfe9 24%, #b3c9df 48%, #c1c0d3 72%, #dfacb9 100%)', + }, { name: '红色', value: '#ec4141' }, { name: '橙色', value: '#f97316' }, { name: '金色', value: '#eab308' }, @@ -282,6 +364,16 @@ const activeCategory = ref('storage'); const isLoaded = ref(false); const enableTransition = ref(false); const plugins = ref([]); +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({ persistCache: true, @@ -289,7 +381,7 @@ const settings = reactive({ const appearance = reactive({ theme: 'dark' as 'dark' | 'light', - accentColor: '#ec4141', + accentColor: '#8289d3', }); const cacheInfo = reactive({ @@ -374,18 +466,58 @@ const loadAppearance = async () => { if (window.electronAPI?.settings) { const allSettings = await window.electronAPI.settings.getAll(); 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); 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') => { document.documentElement.setAttribute('data-theme', theme); }; const applyAccentColor = (color: string) => { 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') => { @@ -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 () => { if (window.electronAPI) { await window.electronAPI.setCachePersist(settings.persistCache); @@ -450,7 +599,7 @@ const changeCacheLocation = async () => { // Load settings BEFORE mount to avoid visual flicker onBeforeMount(async () => { - await Promise.all([loadCacheInfo(), loadAppearance()]); + await Promise.all([loadCacheInfo(), loadAppearance(), loadPrivacy()]); isLoaded.value = true; // Enable transition after initial render nextTick(() => { @@ -680,7 +829,7 @@ onBeforeMount(async () => { } input:checked + .toggle-slider { - background-color: var(--color-accent); + background: var(--color-accent-gradient); } input:checked + .toggle-slider:before { @@ -835,21 +984,24 @@ input:checked + .toggle-slider:before { height: 36px; border-radius: var(--radius-full); border: 3px solid transparent; - background-color: var(--swatch-color); + background: var(--swatch-bg); cursor: pointer; 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; align-items: center; justify-content: center; + outline: 1px solid color-mix(in srgb, var(--swatch-color) 22%, transparent); + outline-offset: 2px; } .color-swatch:hover { - transform: scale(1.1); + border-color: color-mix(in srgb, var(--swatch-color) 34%, transparent); } .color-swatch.active { - box-shadow: 0 0 16px var(--swatch-color); + border-color: var(--color-bg-primary); + outline-color: var(--swatch-color); } .check-icon { @@ -895,6 +1047,77 @@ input:checked + .toggle-slider:before { 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-list { display: flex; diff --git a/src/renderer/src/components/Sidebar.vue b/src/renderer/src/components/Sidebar.vue index dd7a8fc..92cd3d2 100644 --- a/src/renderer/src/components/Sidebar.vue +++ b/src/renderer/src/components/Sidebar.vue @@ -1,94 +1,196 @@ diff --git a/src/renderer/src/components/SongTile.vue b/src/renderer/src/components/SongTile.vue new file mode 100644 index 0000000..f5d2d8f --- /dev/null +++ b/src/renderer/src/components/SongTile.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/src/renderer/src/components/TopBar.vue b/src/renderer/src/components/TopBar.vue index 8325072..00ea581 100644 --- a/src/renderer/src/components/TopBar.vue +++ b/src/renderer/src/components/TopBar.vue @@ -2,11 +2,11 @@
@@ -14,36 +14,63 @@
-
- +
+ + +
+ +
- - - -
@@ -51,282 +78,357 @@ \ No newline at end of file + diff --git a/src/renderer/src/components/player/MusicInfo.vue b/src/renderer/src/components/player/MusicInfo.vue index bf722c9..4b5dd7c 100644 --- a/src/renderer/src/components/player/MusicInfo.vue +++ b/src/renderer/src/components/player/MusicInfo.vue @@ -23,7 +23,7 @@ diff --git a/src/renderer/src/components/player/PlayerQueueList.vue b/src/renderer/src/components/player/PlayerQueueList.vue new file mode 100644 index 0000000..5b3e64f --- /dev/null +++ b/src/renderer/src/components/player/PlayerQueueList.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/src/renderer/src/components/player/TextMarquee.vue b/src/renderer/src/components/player/TextMarquee.vue index 69f2bf4..14696a6 100644 --- a/src/renderer/src/components/player/TextMarquee.vue +++ b/src/renderer/src/components/player/TextMarquee.vue @@ -15,7 +15,7 @@ diff --git a/src/renderer/src/views/ListenRank.vue b/src/renderer/src/views/ListenRank.vue new file mode 100644 index 0000000..4c107c9 --- /dev/null +++ b/src/renderer/src/views/ListenRank.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/src/renderer/src/views/ListenStats.vue b/src/renderer/src/views/ListenStats.vue new file mode 100644 index 0000000..bf54ae4 --- /dev/null +++ b/src/renderer/src/views/ListenStats.vue @@ -0,0 +1,606 @@ + + + + + diff --git a/src/renderer/src/views/ListenTogether.vue b/src/renderer/src/views/ListenTogether.vue new file mode 100644 index 0000000..3c791ab --- /dev/null +++ b/src/renderer/src/views/ListenTogether.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/src/renderer/src/views/LocalMusic.vue b/src/renderer/src/views/LocalMusic.vue index 3f65038..38836a2 100644 --- a/src/renderer/src/views/LocalMusic.vue +++ b/src/renderer/src/views/LocalMusic.vue @@ -1,54 +1,683 @@ diff --git a/src/renderer/src/views/Playlist.vue b/src/renderer/src/views/Playlist.vue index e41d53f..953a3a6 100644 --- a/src/renderer/src/views/Playlist.vue +++ b/src/renderer/src/views/Playlist.vue @@ -1,469 +1,1069 @@ diff --git a/src/renderer/src/views/PlaylistSquare.vue b/src/renderer/src/views/PlaylistSquare.vue new file mode 100644 index 0000000..8dd8d26 --- /dev/null +++ b/src/renderer/src/views/PlaylistSquare.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/src/renderer/src/views/Search.vue b/src/renderer/src/views/Search.vue index 4a6bd8e..ec9c5b3 100644 --- a/src/renderer/src/views/Search.vue +++ b/src/renderer/src/views/Search.vue @@ -66,24 +66,14 @@
-
-
{{ (currentPage - 1) * limit + i + 1 }}
-
- -
-
-
-

-

-
-
-
{{ song.duration }}
-
+
@@ -130,6 +120,7 @@ import { useRoute } from 'vue-router'; import { Icon } from '@iconify/vue'; import { usePlayerStore } from '../stores/player'; import { transformSearchSong } from '../utils/songUtils'; +import SongTile from '../components/SongTile.vue'; import type { Song } from '../types/song'; const route = useRoute(); @@ -345,11 +336,12 @@ onBeforeUnmount(() => { width: 100%; height: 100%; overflow-y: auto; + scroll-padding-bottom: 148px; } .content-wrapper { 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%; /* Removed max-width to allow full width usage as requested */ /* margin: 0 auto; */ @@ -383,7 +375,7 @@ onBeforeUnmount(() => { transform: translateY(-50%); width: 4px; height: 70%; - background-color: var(--color-accent); + background: var(--color-accent-gradient); border-radius: 4px; } @@ -561,7 +553,7 @@ onBeforeUnmount(() => { width: 12px; height: 12px; border-radius: 50%; - background: var(--color-accent); + background: var(--color-accent-gradient); cursor: pointer; box-shadow: 0 0 4px rgba(0,0,0,0.2); } @@ -571,7 +563,7 @@ onBeforeUnmount(() => { top: 0; left: 0; height: 100%; - background: var(--color-accent); + background: var(--color-accent-gradient); border-radius: 2px; pointer-events: none; z-index: 1; @@ -797,7 +789,7 @@ onBeforeUnmount(() => { } .pagination-btn.active { - background: var(--color-accent); + background: var(--color-accent-gradient); color: #fff; /* Ensure readable text on accent */ border-color: var(--color-accent); font-weight: bold; @@ -851,7 +843,7 @@ onBeforeUnmount(() => { .retry-btn { margin-top: 8px; padding: 8px 24px; - background: var(--color-accent); + background: var(--color-accent-gradient); color: white; /* Ensure text is readable on accent color */ border-radius: var(--radius-full); font-size: 14px; diff --git a/src/renderer/src/views/UserProfile.vue b/src/renderer/src/views/UserProfile.vue new file mode 100644 index 0000000..1a3fe5f --- /dev/null +++ b/src/renderer/src/views/UserProfile.vue @@ -0,0 +1,625 @@ + + + + +