mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-06-20 23:35:06 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af8c4f953 |
54
native/taglib_reader/build.ps1
Normal file
54
native/taglib_reader/build.ps1
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $root "..\..")
|
||||||
|
$outDir = Join-Path $root "build"
|
||||||
|
$cliObj = Join-Path $outDir "taglib_reader_cli.obj"
|
||||||
|
$cliOut = Join-Path $outDir "taglib_reader_cli.exe"
|
||||||
|
|
||||||
|
$taglibRoot = "D:\1Music\taglib\taglib\build-win\install"
|
||||||
|
$taglibInclude = Join-Path $taglibRoot "include"
|
||||||
|
$utfcppInclude = Join-Path $taglibRoot "include\utf8cpp"
|
||||||
|
$taglibLib = Join-Path $taglibRoot "lib\tag.lib"
|
||||||
|
|
||||||
|
foreach ($path in @($taglibInclude, $taglibLib)) {
|
||||||
|
if (!(Test-Path $path)) {
|
||||||
|
throw "Missing dependency: $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
|
||||||
|
|
||||||
|
$vsDevCmd = "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"
|
||||||
|
$cliClArgs = @(
|
||||||
|
"/nologo",
|
||||||
|
"/std:c++17",
|
||||||
|
"/EHsc",
|
||||||
|
"/MD",
|
||||||
|
"/O2",
|
||||||
|
"/DTAGLIB_STATIC",
|
||||||
|
"/I`"$taglibInclude`"",
|
||||||
|
"/I`"$utfcppInclude`"",
|
||||||
|
"/c",
|
||||||
|
"`"$(Join-Path $root "taglib_reader_cli.cpp")`"",
|
||||||
|
"/Fo`"$cliObj`""
|
||||||
|
) -join " "
|
||||||
|
|
||||||
|
$cliLinkArgs = @(
|
||||||
|
"/nologo",
|
||||||
|
"/OUT:`"$cliOut`"",
|
||||||
|
"`"$cliObj`"",
|
||||||
|
"`"$taglibLib`"",
|
||||||
|
"Advapi32.lib",
|
||||||
|
"Shell32.lib",
|
||||||
|
"Ole32.lib",
|
||||||
|
"User32.lib"
|
||||||
|
) -join " "
|
||||||
|
|
||||||
|
$command = "`"$vsDevCmd`" -arch=x64 && cl $cliClArgs && link $cliLinkArgs"
|
||||||
|
& $env:ComSpec /d /c $command
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Native build failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Built $cliOut"
|
||||||
BIN
native/taglib_reader/build/taglib_reader_cli.exe
Normal file
BIN
native/taglib_reader/build/taglib_reader_cli.exe
Normal file
Binary file not shown.
403
native/taglib_reader/taglib_reader_cli.cpp
Normal file
403
native/taglib_reader/taglib_reader_cli.cpp
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <iostream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <taglib/attachedpictureframe.h>
|
||||||
|
#include <taglib/fileref.h>
|
||||||
|
#include <taglib/flacfile.h>
|
||||||
|
#include <taglib/flacpicture.h>
|
||||||
|
#include <taglib/id3v2tag.h>
|
||||||
|
#include <taglib/mpegfile.h>
|
||||||
|
#include <taglib/mp4coverart.h>
|
||||||
|
#include <taglib/mp4file.h>
|
||||||
|
#include <taglib/mp4tag.h>
|
||||||
|
#include <taglib/oggfile.h>
|
||||||
|
#include <taglib/tbytevector.h>
|
||||||
|
#include <taglib/tpropertymap.h>
|
||||||
|
#include <taglib/unsynchronizedlyricsframe.h>
|
||||||
|
#include <taglib/vorbisfile.h>
|
||||||
|
#include <taglib/wavfile.h>
|
||||||
|
#include <taglib/xiphcomment.h>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr unsigned int kMaxCoverBytes = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
std::string WideToUtf8(const std::wstring &value) {
|
||||||
|
if (value.empty()) return "";
|
||||||
|
const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||||
|
if (size <= 0) return "";
|
||||||
|
std::string result(size, '\0');
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size, nullptr, nullptr);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring Utf8ToWide(const std::string &value) {
|
||||||
|
if (value.empty()) return L"";
|
||||||
|
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
||||||
|
if (size <= 0) return L"";
|
||||||
|
std::wstring result(size, L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ToUtf8(const TagLib::String &value) {
|
||||||
|
return value.to8Bit(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Trim(const std::string &value) {
|
||||||
|
const auto start = value.find_first_not_of(" \t\r\n");
|
||||||
|
if (start == std::string::npos) return "";
|
||||||
|
const auto end = value.find_last_not_of(" \t\r\n");
|
||||||
|
return value.substr(start, end - start + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string JsonEscape(const std::string &value) {
|
||||||
|
std::ostringstream stream;
|
||||||
|
for (const unsigned char ch : value) {
|
||||||
|
switch (ch) {
|
||||||
|
case '\"': stream << "\\\""; break;
|
||||||
|
case '\\': stream << "\\\\"; break;
|
||||||
|
case '\b': stream << "\\b"; break;
|
||||||
|
case '\f': stream << "\\f"; break;
|
||||||
|
case '\n': stream << "\\n"; break;
|
||||||
|
case '\r': stream << "\\r"; break;
|
||||||
|
case '\t': stream << "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (ch < 0x20) {
|
||||||
|
stream << "\\u";
|
||||||
|
const char *hex = "0123456789abcdef";
|
||||||
|
stream << "00" << hex[(ch >> 4) & 0x0F] << hex[ch & 0x0F];
|
||||||
|
} else {
|
||||||
|
stream << ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Q(const std::string &value) {
|
||||||
|
return "\"" + JsonEscape(value) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DetectMimeType(const TagLib::ByteVector &data) {
|
||||||
|
if (data.size() >= 4) {
|
||||||
|
const auto *d = reinterpret_cast<const unsigned char *>(data.data());
|
||||||
|
if (d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF) return "image/jpeg";
|
||||||
|
if (d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47) return "image/png";
|
||||||
|
if (d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38) return "image/gif";
|
||||||
|
if (data.size() >= 12 && d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
|
||||||
|
d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50) {
|
||||||
|
return "image/webp";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CoverExtension(const std::string &mime) {
|
||||||
|
if (mime == "image/png") return ".png";
|
||||||
|
if (mime == "image/gif") return ".gif";
|
||||||
|
if (mime == "image/webp") return ".webp";
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string HexHash(const std::string &value) {
|
||||||
|
uint64_t hash = 1469598103934665603ull;
|
||||||
|
for (const unsigned char ch : value) {
|
||||||
|
hash ^= ch;
|
||||||
|
hash *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << std::hex << std::setw(16) << std::setfill('0') << hash;
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string WriteCoverFile(
|
||||||
|
const TagLib::ByteVector &cover,
|
||||||
|
const fs::path &filePath,
|
||||||
|
const fs::path &artworkDir,
|
||||||
|
const std::string &mime) {
|
||||||
|
if (cover.isEmpty() || cover.size() > kMaxCoverBytes || artworkDir.empty()) return "";
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(artworkDir, ec);
|
||||||
|
if (ec) return "";
|
||||||
|
|
||||||
|
const auto id = HexHash(WideToUtf8(filePath.wstring()));
|
||||||
|
const auto outPath = artworkDir / fs::path(Utf8ToWide(id + CoverExtension(mime)));
|
||||||
|
std::ofstream out(outPath, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) return "";
|
||||||
|
out.write(cover.data(), cover.size());
|
||||||
|
if (!out) return "";
|
||||||
|
return WideToUtf8(outPath.wstring());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Base64Encode(const TagLib::ByteVector &data) {
|
||||||
|
static constexpr char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
std::string output;
|
||||||
|
output.reserve(((data.size() + 2) / 3) * 4);
|
||||||
|
|
||||||
|
const auto *bytes = reinterpret_cast<const unsigned char *>(data.data());
|
||||||
|
for (unsigned int i = 0; i < data.size(); i += 3) {
|
||||||
|
const unsigned int b0 = bytes[i];
|
||||||
|
const unsigned int b1 = i + 1 < data.size() ? bytes[i + 1] : 0;
|
||||||
|
const unsigned int b2 = i + 2 < data.size() ? bytes[i + 2] : 0;
|
||||||
|
output.push_back(table[(b0 >> 2) & 0x3F]);
|
||||||
|
output.push_back(table[((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0F)]);
|
||||||
|
output.push_back(i + 1 < data.size() ? table[((b1 & 0x0F) << 2) | ((b2 >> 6) & 0x03)] : '=');
|
||||||
|
output.push_back(i + 2 < data.size() ? table[b2 & 0x3F] : '=');
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FormatDuration(int seconds) {
|
||||||
|
if (seconds <= 0) return "00:00";
|
||||||
|
const int minutes = seconds / 60;
|
||||||
|
const int rest = seconds % 60;
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << minutes << ':';
|
||||||
|
if (rest < 10) stream << '0';
|
||||||
|
stream << rest;
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string BuildQuality(TagLib::File *file, int bitrate, int sampleRate) {
|
||||||
|
if (auto *flac = dynamic_cast<TagLib::FLAC::File *>(file)) {
|
||||||
|
if (flac->audioProperties()) {
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << (sampleRate / 1000.0) << "kHz " << flac->audioProperties()->bitsPerSample() << "bit";
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
|
||||||
|
if (wav->audioProperties()) {
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << (sampleRate / 1000.0) << "kHz " << wav->audioProperties()->bitsPerSample() << "bit";
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitrate > 0) return std::to_string(bitrate) + "kbps";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
TagLib::ByteVector ReadCover(TagLib::File *file) {
|
||||||
|
if (auto *mp3 = dynamic_cast<TagLib::MPEG::File *>(file)) {
|
||||||
|
if (auto *id3 = mp3->ID3v2Tag()) {
|
||||||
|
const auto frames = id3->frameList("APIC");
|
||||||
|
if (!frames.isEmpty()) {
|
||||||
|
if (auto *frame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frames.front())) return frame->picture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
|
||||||
|
if (auto *id3 = wav->ID3v2Tag()) {
|
||||||
|
const auto frames = id3->frameList("APIC");
|
||||||
|
if (!frames.isEmpty()) {
|
||||||
|
if (auto *frame = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(frames.front())) return frame->picture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *flac = dynamic_cast<TagLib::FLAC::File *>(file)) {
|
||||||
|
const auto pictures = flac->pictureList();
|
||||||
|
if (!pictures.isEmpty()) return pictures.front()->data();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *mp4 = dynamic_cast<TagLib::MP4::File *>(file)) {
|
||||||
|
if (auto *tag = mp4->tag(); tag && tag->contains("covr")) {
|
||||||
|
const auto covers = tag->item("covr").toCoverArtList();
|
||||||
|
if (!covers.isEmpty()) return covers.front().data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *vorbis = dynamic_cast<TagLib::Ogg::Vorbis::File *>(file)) {
|
||||||
|
if (auto *tag = vorbis->tag()) {
|
||||||
|
const auto pictures = tag->pictureList();
|
||||||
|
if (!pictures.isEmpty()) return pictures.front()->data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FirstProperty(TagLib::File *file, const std::vector<std::string> &keys) {
|
||||||
|
if (!file) return "";
|
||||||
|
|
||||||
|
const auto properties = file->properties();
|
||||||
|
for (const auto &rawKey : keys) {
|
||||||
|
const TagLib::String key(rawKey, TagLib::String::UTF8);
|
||||||
|
if (!properties.contains(key)) continue;
|
||||||
|
const auto values = properties[key];
|
||||||
|
if (!values.isEmpty()) {
|
||||||
|
const auto text = Trim(ToUtf8(values.front()));
|
||||||
|
if (!text.empty()) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReadLyrics(TagLib::File *file) {
|
||||||
|
if (!file) return "";
|
||||||
|
|
||||||
|
if (auto *mp3 = dynamic_cast<TagLib::MPEG::File *>(file)) {
|
||||||
|
if (auto *id3 = mp3->ID3v2Tag()) {
|
||||||
|
const auto frames = id3->frameList("USLT");
|
||||||
|
for (auto *rawFrame : frames) {
|
||||||
|
if (auto *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(rawFrame)) {
|
||||||
|
const auto text = Trim(ToUtf8(frame->text()));
|
||||||
|
if (!text.empty()) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto *wav = dynamic_cast<TagLib::RIFF::WAV::File *>(file)) {
|
||||||
|
if (auto *id3 = wav->ID3v2Tag()) {
|
||||||
|
const auto frames = id3->frameList("USLT");
|
||||||
|
for (auto *rawFrame : frames) {
|
||||||
|
if (auto *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(rawFrame)) {
|
||||||
|
const auto text = Trim(ToUtf8(frame->text()));
|
||||||
|
if (!text.empty()) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FirstProperty(file, {
|
||||||
|
"LYRICS",
|
||||||
|
"UNSYNCEDLYRICS",
|
||||||
|
"UNSYNCHRONIZEDLYRICS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsAudioPath(const fs::path &file) {
|
||||||
|
const auto ext = file.extension().wstring();
|
||||||
|
const std::wstring lower = [&]() {
|
||||||
|
std::wstring out = ext;
|
||||||
|
for (auto &ch : out) ch = static_cast<wchar_t>(towlower(ch));
|
||||||
|
return out;
|
||||||
|
}();
|
||||||
|
return lower == L".mp3" || lower == L".flac" || lower == L".m4a" || lower == L".mp4" ||
|
||||||
|
lower == L".aac" || lower == L".ogg" || lower == L".opus" || lower == L".wav" ||
|
||||||
|
lower == L".aiff" || lower == L".aif" || lower == L".ape" || lower == L".wv";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReadFileJson(const fs::path &filePath, const fs::path &artworkDir = {}) {
|
||||||
|
const std::wstring nativePath = filePath.wstring();
|
||||||
|
TagLib::FileRef fileRef(TagLib::FileName(nativePath.c_str()), true, TagLib::AudioProperties::Average);
|
||||||
|
if (fileRef.isNull() || !fileRef.file()) {
|
||||||
|
throw std::runtime_error("Unsupported or unreadable audio file");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *tag = fileRef.tag();
|
||||||
|
auto *file = fileRef.file();
|
||||||
|
auto *props = fileRef.audioProperties();
|
||||||
|
const int seconds = props ? props->lengthInSeconds() : 0;
|
||||||
|
const int bitrate = props ? props->bitrate() : 0;
|
||||||
|
const int sampleRate = props ? props->sampleRate() : 0;
|
||||||
|
const int channels = props ? props->channels() : 0;
|
||||||
|
const auto cover = ReadCover(file);
|
||||||
|
const bool includeCover = !cover.isEmpty() && cover.size() <= kMaxCoverBytes;
|
||||||
|
const auto mime = includeCover ? DetectMimeType(cover) : "";
|
||||||
|
const auto coverPath = includeCover ? WriteCoverFile(cover, filePath, artworkDir, mime) : "";
|
||||||
|
const auto lyric = ReadLyrics(file);
|
||||||
|
|
||||||
|
std::ostringstream json;
|
||||||
|
json << "{";
|
||||||
|
json << "\"path\":" << Q(WideToUtf8(filePath.wstring())) << ",";
|
||||||
|
json << "\"title\":" << Q(tag ? ToUtf8(tag->title()) : "") << ",";
|
||||||
|
json << "\"artist\":" << Q(tag ? ToUtf8(tag->artist()) : "") << ",";
|
||||||
|
json << "\"album\":" << Q(tag ? ToUtf8(tag->album()) : "") << ",";
|
||||||
|
json << "\"comment\":" << Q(tag ? ToUtf8(tag->comment()) : "") << ",";
|
||||||
|
json << "\"genre\":" << Q(tag ? ToUtf8(tag->genre()) : "") << ",";
|
||||||
|
json << "\"year\":" << (tag ? tag->year() : 0) << ",";
|
||||||
|
json << "\"track\":" << (tag ? tag->track() : 0) << ",";
|
||||||
|
json << "\"durationSeconds\":" << seconds << ",";
|
||||||
|
json << "\"duration\":" << Q(FormatDuration(seconds)) << ",";
|
||||||
|
json << "\"bitrate\":" << bitrate << ",";
|
||||||
|
json << "\"sampleRate\":" << sampleRate << ",";
|
||||||
|
json << "\"channels\":" << channels << ",";
|
||||||
|
json << "\"quality\":" << Q(BuildQuality(file, bitrate, sampleRate)) << ",";
|
||||||
|
json << "\"coverMime\":" << Q(mime) << ",";
|
||||||
|
json << "\"coverPath\":" << Q(coverPath) << ",";
|
||||||
|
json << "\"coverDataUrl\":" << Q("") << ",";
|
||||||
|
json << "\"lyric\":" << Q(lyric);
|
||||||
|
json << "}";
|
||||||
|
return json.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<fs::path> CollectAudioFiles(const std::vector<fs::path> &roots) {
|
||||||
|
std::vector<fs::path> files;
|
||||||
|
for (const auto &root : roots) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::exists(root, ec) || !fs::is_directory(root, ec)) continue;
|
||||||
|
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec);
|
||||||
|
fs::recursive_directory_iterator end;
|
||||||
|
while (it != end) {
|
||||||
|
if (!ec && it->is_regular_file(ec) && IsAudioPath(it->path())) files.push_back(it->path());
|
||||||
|
it.increment(ec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int wmain(int argc, wchar_t **argv) {
|
||||||
|
SetConsoleOutputCP(CP_UTF8);
|
||||||
|
if (argc < 3) {
|
||||||
|
std::cerr << "Usage: taglib_reader_cli.exe read <file> | scan [--artwork-dir <dir>] <dir...>\n";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const std::wstring mode = argv[1];
|
||||||
|
if (mode == L"read") {
|
||||||
|
std::cout << ReadFileJson(fs::path(argv[2]));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == L"scan") {
|
||||||
|
fs::path artworkDir;
|
||||||
|
std::vector<fs::path> roots;
|
||||||
|
int startIndex = 2;
|
||||||
|
if (argc >= 5 && std::wstring(argv[2]) == L"--artwork-dir") {
|
||||||
|
artworkDir = fs::path(argv[3]);
|
||||||
|
startIndex = 4;
|
||||||
|
}
|
||||||
|
for (int i = startIndex; i < argc; i++) roots.emplace_back(argv[i]);
|
||||||
|
const auto files = CollectAudioFiles(roots);
|
||||||
|
|
||||||
|
std::cout << "{\"songs\":[";
|
||||||
|
bool first = true;
|
||||||
|
for (const auto &file : files) {
|
||||||
|
try {
|
||||||
|
if (!first) std::cout << ",";
|
||||||
|
std::cout << ReadFileJson(file, artworkDir);
|
||||||
|
first = false;
|
||||||
|
} catch (const std::exception &error) {
|
||||||
|
std::cerr << "Failed to read " << WideToUtf8(file.wstring()) << ": " << error.what() << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::cout << "]}";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cerr << "Unknown mode\n";
|
||||||
|
return 2;
|
||||||
|
} catch (const std::exception &error) {
|
||||||
|
std::cerr << error.what() << "\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
217
package-lock.json
generated
217
package-lock.json
generated
@@ -21,11 +21,13 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"element-plus": "^2.13.7",
|
"element-plus": "^2.13.7",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
"jss-preset-default": "^10.10.0",
|
"jss-preset-default": "^10.10.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-wasm": "^3.6.0",
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
@@ -3762,6 +3764,15 @@
|
|||||||
"xmlbuilder": ">=11.0.1"
|
"xmlbuilder": ">=11.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/responselike": {
|
"node_modules/@types/responselike": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz",
|
||||||
@@ -4391,7 +4402,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4401,7 +4411,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -5300,6 +5309,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001768",
|
"version": "1.0.30001768",
|
||||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz",
|
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz",
|
||||||
@@ -5461,7 +5479,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -5474,7 +5491,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/colorjs.io": {
|
"node_modules/colorjs.io": {
|
||||||
@@ -5908,6 +5924,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -6057,6 +6082,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dir-compare": {
|
"node_modules/dir-compare": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-3.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-3.3.0.tgz",
|
||||||
@@ -7056,7 +7087,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/empathic": {
|
"node_modules/empathic": {
|
||||||
@@ -7578,7 +7608,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -8157,7 +8186,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -9698,6 +9726,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -9740,7 +9777,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -9913,6 +9949,15 @@
|
|||||||
"node": ">=10.4.0"
|
"node": ">=10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -10088,6 +10133,141 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.1.tgz",
|
||||||
@@ -10224,12 +10404,17 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -11106,6 +11291,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -11437,7 +11628,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -11468,7 +11658,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -13097,6 +13286,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
|
|||||||
@@ -25,11 +25,13 @@
|
|||||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||||
"@pixi/filter-color-matrix": "^7.4.3",
|
"@pixi/filter-color-matrix": "^7.4.3",
|
||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"element-plus": "^2.13.7",
|
"element-plus": "^2.13.7",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
"jss-preset-default": "^10.10.0",
|
"jss-preset-default": "^10.10.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"vite-plugin-wasm": "^3.6.0",
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
@@ -72,6 +74,10 @@
|
|||||||
{
|
{
|
||||||
"from": "core/libfftw3f-3.dll",
|
"from": "core/libfftw3f-3.dll",
|
||||||
"to": "core/libfftw3f-3.dll"
|
"to": "core/libfftw3f-3.dll"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "native/taglib_reader/build/taglib_reader_cli.exe",
|
||||||
|
"to": "native/taglib_reader_cli.exe"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
380
src/main/authStore.ts
Normal file
380
src/main/authStore.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import { app, shell } from 'electron'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://api.qz.shiqianjiang.cn/app'
|
||||||
|
const AUTH_LOGIN_URL = `${API_BASE_URL}/auth/login`
|
||||||
|
const UPLOAD_ENDPOINT = 'https://picgo.re-link.top'
|
||||||
|
const UPLOAD_TOKEN_SECRET = 'pm9K2nBSseKihywiDH3hiaGJwzyTGQwj'
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
|
nickname?: string | null
|
||||||
|
gender?: string | null
|
||||||
|
region?: string | null
|
||||||
|
intro?: string | null
|
||||||
|
birthday?: string | null
|
||||||
|
subscribing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
exp: number
|
||||||
|
userInfo: UserInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthCallbackPayload {
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
user_info?: UserInfo
|
||||||
|
access_token?: string
|
||||||
|
refresh_token?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrLoginSession {
|
||||||
|
status: string
|
||||||
|
session_id: string
|
||||||
|
poll_token: string
|
||||||
|
qr_payload: string
|
||||||
|
qr_data_url: string
|
||||||
|
expires_at: number
|
||||||
|
expires_in: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrLoginPollResult extends AuthCallbackPayload {
|
||||||
|
state?: AuthState
|
||||||
|
device_name?: string
|
||||||
|
expires_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_AUTH_STATE: AuthState = {
|
||||||
|
accessToken: '',
|
||||||
|
refreshToken: '',
|
||||||
|
exp: 0,
|
||||||
|
userInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let authCache: AuthState | null = null
|
||||||
|
|
||||||
|
function getAuthPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'auth.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthPayload(payload: AuthCallbackPayload): AuthState {
|
||||||
|
const rawExp = Number(payload.exp) || 0
|
||||||
|
const exp = rawExp > 0 && rawExp < 10_000_000_000 ? rawExp * 1000 : rawExp
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: payload.access_token || '',
|
||||||
|
refreshToken: payload.refresh_token || '',
|
||||||
|
exp,
|
||||||
|
userInfo: payload.user_info || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAuthState(): AuthState {
|
||||||
|
if (authCache) return authCache
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authPath = getAuthPath()
|
||||||
|
if (fs.existsSync(authPath)) {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(authPath, 'utf8'))
|
||||||
|
const nextState = { ...EMPTY_AUTH_STATE, ...raw }
|
||||||
|
if (nextState.exp > 0 && nextState.exp < 10_000_000_000) {
|
||||||
|
nextState.exp *= 1000
|
||||||
|
}
|
||||||
|
authCache = nextState
|
||||||
|
return nextState
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Failed to load auth state:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = { ...EMPTY_AUTH_STATE }
|
||||||
|
authCache = nextState
|
||||||
|
return nextState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuthState(nextState: AuthState): AuthState {
|
||||||
|
authCache = { ...EMPTY_AUTH_STATE, ...nextState }
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(getAuthPath(), JSON.stringify(authCache, null, 2), 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Failed to save auth state:', err)
|
||||||
|
}
|
||||||
|
return authCache
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthState(): AuthState {
|
||||||
|
authCache = { ...EMPTY_AUTH_STATE }
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getAuthPath())) fs.unlinkSync(getAuthPath())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Failed to clear auth state:', err)
|
||||||
|
}
|
||||||
|
return authCache
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openLoginPage(forcePrompt = false): Promise<{ success: boolean; url: string }> {
|
||||||
|
const url = forcePrompt ? `${AUTH_LOGIN_URL}?prompt=login` : AUTH_LOGIN_URL
|
||||||
|
await shell.openExternal(url)
|
||||||
|
return { success: true, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authQrFetch<T>(pathname: string, body: any): Promise<T> {
|
||||||
|
const resp = await fetch(`${API_BASE_URL}${pathname}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const message = await resp.text().catch(() => '')
|
||||||
|
throw new Error(message || `Request failed: ${resp.status}`)
|
||||||
|
}
|
||||||
|
return resp.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQrLoginSession(): Promise<QrLoginSession> {
|
||||||
|
const deviceName = `${app.getName() || 'QZMusic'} on ${os.hostname() || process.platform}`
|
||||||
|
const payload = await authQrFetch<Omit<QrLoginSession, 'qr_data_url'>>('/auth/qr/session', {
|
||||||
|
device_name: deviceName,
|
||||||
|
client: 'qzmusic-electron',
|
||||||
|
platform: process.platform,
|
||||||
|
})
|
||||||
|
if (payload.status !== 'success') {
|
||||||
|
throw new Error(payload.message || 'Create QR login session failed')
|
||||||
|
}
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(payload.qr_payload, {
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
margin: 1,
|
||||||
|
width: 256,
|
||||||
|
})
|
||||||
|
return { ...payload, qr_data_url: qrDataUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollQrLoginSession(sessionId: string, pollToken: string): Promise<QrLoginPollResult> {
|
||||||
|
const payload = await authQrFetch<QrLoginPollResult>('/auth/qr/poll', {
|
||||||
|
session_id: sessionId,
|
||||||
|
poll_token: pollToken,
|
||||||
|
})
|
||||||
|
if (payload.status === 'success' && payload.access_token) {
|
||||||
|
const state = acceptAuthCallback(payload)
|
||||||
|
return { ...payload, state }
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelQrLoginSession(sessionId: string, pollToken: string): Promise<any> {
|
||||||
|
return authQrFetch('/auth/qr/cancel', {
|
||||||
|
session_id: sessionId,
|
||||||
|
poll_token: pollToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acceptAuthCallback(payload: AuthCallbackPayload): AuthState {
|
||||||
|
if (payload.status !== 'success') {
|
||||||
|
throw new Error(payload.message || 'Login failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = normalizeAuthPayload(payload)
|
||||||
|
if (!nextState.accessToken || !nextState.refreshToken || !nextState.userInfo?.id) {
|
||||||
|
throw new Error('Invalid login response')
|
||||||
|
}
|
||||||
|
return saveAuthState(nextState)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuthState(): Promise<AuthState> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
if (!state.accessToken || !state.refreshToken) return state
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE_URL}/auth/refresh_token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
access_token: state.accessToken,
|
||||||
|
refresh_token: state.refreshToken,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Refresh token failed: ${resp.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await resp.json() as AuthCallbackPayload
|
||||||
|
if (payload.status !== 'success') {
|
||||||
|
throw new Error(payload.message || 'Login expired')
|
||||||
|
}
|
||||||
|
const refreshed = normalizeAuthPayload(payload)
|
||||||
|
return saveAuthState({
|
||||||
|
...state,
|
||||||
|
...refreshed,
|
||||||
|
userInfo: refreshed.userInfo || state.userInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getValidAccessToken(): Promise<string> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
if (!state.accessToken) return ''
|
||||||
|
|
||||||
|
if (Date.now() > state.exp - 60_000) {
|
||||||
|
const refreshed = await refreshAuthState()
|
||||||
|
return refreshed.accessToken
|
||||||
|
}
|
||||||
|
return state.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qzFetch(pathname: string, init: RequestInit = {}): Promise<any> {
|
||||||
|
const token = await getValidAccessToken()
|
||||||
|
const headers = new Headers(init.headers)
|
||||||
|
headers.set('Content-Type', headers.get('Content-Type') || 'application/json')
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE_URL}${pathname}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const message = await resp.text().catch(() => '')
|
||||||
|
throw new Error(message || `Request failed: ${resp.status}`)
|
||||||
|
}
|
||||||
|
if (resp.status === 204) return null
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeForImage(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (ext === '.png') return 'image/png'
|
||||||
|
if (ext === '.webp') return 'image/webp'
|
||||||
|
if (ext === '.gif') return 'image/gif'
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadToken(userId: string, timestamp: string): string {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', UPLOAD_TOKEN_SECRET)
|
||||||
|
.update(`${userId}.${timestamp}`)
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadImage(filePath: string): Promise<{ success: boolean; url?: string; message?: string }> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const userId = state.userInfo?.id
|
||||||
|
if (!userId) throw new Error('Not logged in')
|
||||||
|
if (!fs.existsSync(filePath)) throw new Error('Image file not found')
|
||||||
|
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('userid', userId)
|
||||||
|
form.append('timestamp', timestamp)
|
||||||
|
form.append('token', uploadToken(userId, timestamp))
|
||||||
|
form.append(
|
||||||
|
'file',
|
||||||
|
new Blob([new Uint8Array(fs.readFileSync(filePath))], { type: mimeForImage(filePath) }),
|
||||||
|
path.basename(filePath),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resp = await fetch(UPLOAD_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Origin: 'https://re-link.top',
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
const text = await resp.text()
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(text || `Upload failed: ${resp.status}`)
|
||||||
|
}
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
if (!data?.ok) {
|
||||||
|
throw new Error(data?.message || 'Upload failed')
|
||||||
|
}
|
||||||
|
const url = String(data.url || data.display_url || '')
|
||||||
|
if (!url) throw new Error('Upload response missing url')
|
||||||
|
return { success: true, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListenTogetherWsUrl(params: Record<string, string>): string {
|
||||||
|
const query = new URLSearchParams(params)
|
||||||
|
return `wss://interface.qz.folltoshe.com/ws?${query.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPcHeartbeat(duration: number, timestamp = Date.now()): Promise<any> {
|
||||||
|
return qzFetch('/heartbeat/pc', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ duration, timestamp }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListenTime(detail = 1, userId?: string): Promise<any> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const targetUserId = userId || state.userInfo?.id
|
||||||
|
if (!targetUserId) throw new Error('Not logged in')
|
||||||
|
return qzFetch(`/user/${encodeURIComponent(targetUserId)}/stat/listen/time?detail=${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListenTimeRange(start: string, end: string, userId?: string): Promise<any> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const targetUserId = userId || state.userInfo?.id
|
||||||
|
if (!targetUserId) throw new Error('Not logged in')
|
||||||
|
const query = new URLSearchParams({ start, end })
|
||||||
|
return qzFetch(`/user/${encodeURIComponent(targetUserId)}/stat/listen/range?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListenRank(period: 'week' | 'month' | 'year' = 'week', limit = 200): Promise<any> {
|
||||||
|
const query = new URLSearchParams({ period, limit: String(Math.max(1, Math.min(200, limit))) })
|
||||||
|
return qzFetch(`/user/stat/listen/rank?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserProfile(userId: string): Promise<UserInfo> {
|
||||||
|
if (!userId) throw new Error('Missing user id')
|
||||||
|
return qzFetch(`/user/${encodeURIComponent(userId)}/info`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPublicPlaylists(userId: string): Promise<any[]> {
|
||||||
|
if (!userId) throw new Error('Missing user id')
|
||||||
|
const result = await qzFetch(`/user/${encodeURIComponent(userId)}/playlists`)
|
||||||
|
return Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPublicFavSongs(userId: string): Promise<any[]> {
|
||||||
|
if (!userId) throw new Error('Missing user id')
|
||||||
|
const result = await qzFetch(`/user/${encodeURIComponent(userId)}/fav/songs`)
|
||||||
|
return Array.isArray(result) ? result : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCurrentUserProfile(payload: Partial<UserInfo>): Promise<UserInfo> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const userId = state.userInfo?.id
|
||||||
|
if (!userId) throw new Error('Not logged in')
|
||||||
|
await qzFetch(`/user/${encodeURIComponent(userId)}/info`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
const userInfo = await getUserProfile(userId)
|
||||||
|
saveAuthState({ ...state, userInfo })
|
||||||
|
return userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLibraryPrivacy(): Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const userId = state.userInfo?.id
|
||||||
|
if (!userId) throw new Error('Not logged in')
|
||||||
|
return qzFetch(`/user/${encodeURIComponent(userId)}/privacy/library`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLibraryPrivacy(payload: { allow_public_library?: boolean; allow_public_profile?: boolean }): Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }> {
|
||||||
|
const state = loadAuthState()
|
||||||
|
const userId = state.userInfo?.id
|
||||||
|
if (!userId) throw new Error('Not logged in')
|
||||||
|
return qzFetch(`/user/${encodeURIComponent(userId)}/privacy/library`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,53 @@ import { QzpController } from './qzpController'
|
|||||||
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow, refreshCacheDir } from './proxyServer'
|
import { startProxyServer, cleanupCache, getCacheDir, getCacheSize, setPersistCache, clearCacheNow, refreshCacheDir } from './proxyServer'
|
||||||
import { PluginSystem } from './pluginSystem'
|
import { PluginSystem } from './pluginSystem'
|
||||||
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
|
import { loadSettings, saveSettings, getSetting, AppSettings } from './settingsStore'
|
||||||
|
import {
|
||||||
|
acceptAuthCallback,
|
||||||
|
cancelQrLoginSession,
|
||||||
|
clearAuthState,
|
||||||
|
createQrLoginSession,
|
||||||
|
getLibraryPrivacy,
|
||||||
|
getListenTogetherWsUrl,
|
||||||
|
getListenRank,
|
||||||
|
getValidAccessToken,
|
||||||
|
getListenTime,
|
||||||
|
getListenTimeRange,
|
||||||
|
getUserProfile,
|
||||||
|
getUserPublicFavSongs,
|
||||||
|
getUserPublicPlaylists,
|
||||||
|
loadAuthState,
|
||||||
|
openLoginPage,
|
||||||
|
pollQrLoginSession,
|
||||||
|
refreshAuthState,
|
||||||
|
sendPcHeartbeat,
|
||||||
|
setLibraryPrivacy,
|
||||||
|
updateCurrentUserProfile,
|
||||||
|
uploadImage,
|
||||||
|
type AuthCallbackPayload,
|
||||||
|
} from './authStore'
|
||||||
|
import {
|
||||||
|
addSong,
|
||||||
|
copyPlaylistToLocal,
|
||||||
|
convertPlaylistScope,
|
||||||
|
createPlaylist,
|
||||||
|
deletePlaylist as deleteStoredPlaylist,
|
||||||
|
exportPlaylist,
|
||||||
|
getPlaylist,
|
||||||
|
importPlaylist,
|
||||||
|
listCloudPlaylists,
|
||||||
|
listLocalPlaylists,
|
||||||
|
listPublicPlaylists,
|
||||||
|
removeSong,
|
||||||
|
updatePlaylist,
|
||||||
|
type PlaylistScope,
|
||||||
|
} from './playlistStore'
|
||||||
|
import {
|
||||||
|
clearMissingLocalSongs,
|
||||||
|
getLocalMusicLibrary,
|
||||||
|
removeLocalSong,
|
||||||
|
scanLocalMusic,
|
||||||
|
setLocalMusicRoots,
|
||||||
|
} from './localMusicStore'
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
@@ -26,6 +73,65 @@ function notifyPluginsChanged(action: 'installed' | 'updated' | 'uninstalled', p
|
|||||||
win?.webContents.send('plugin:changed', { action, pluginId })
|
win?.webContents.send('plugin:changed', { action, pluginId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAuthCallback(url: string): AuthCallbackPayload | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.protocol !== 'qzmusic:' || parsed.hostname !== 'auth_result') return null
|
||||||
|
const hexData = parsed.searchParams.get('data')
|
||||||
|
if (!hexData) return null
|
||||||
|
const jsonText = Buffer.from(hexData, 'hex').toString('utf8')
|
||||||
|
return JSON.parse(jsonText)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] Failed to decode callback:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthCallback(url: string): void {
|
||||||
|
const payload = decodeAuthCallback(url)
|
||||||
|
if (!payload) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = acceptAuthCallback(payload)
|
||||||
|
win?.webContents.send('auth:changed', { status: 'success', state })
|
||||||
|
} catch (err: any) {
|
||||||
|
win?.webContents.send('auth:changed', {
|
||||||
|
status: 'error',
|
||||||
|
message: err?.message || 'Login failed',
|
||||||
|
state: loadAuthState(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (win?.isMinimized()) win.restore()
|
||||||
|
win?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.defaultApp) {
|
||||||
|
if (process.argv.length >= 2) {
|
||||||
|
app.setAsDefaultProtocolClient('qzmusic', process.execPath, [path.resolve(process.argv[1])])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.setAsDefaultProtocolClient('qzmusic')
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
const callbackUrl = argv.find((arg) => arg.startsWith('qzmusic://auth_result'))
|
||||||
|
if (callbackUrl) handleAuthCallback(callbackUrl)
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleAuthCallback(url)
|
||||||
|
})
|
||||||
|
|
||||||
// === Electron 窗口逻辑 ===
|
// === Electron 窗口逻辑 ===
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -65,6 +171,16 @@ ipcMain.on('window-minimize', (event) => BrowserWindow.fromWebContents(event.sen
|
|||||||
ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize())
|
ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?.maximize())
|
||||||
ipcMain.on('window-close', () => win?.close())
|
ipcMain.on('window-close', () => win?.close())
|
||||||
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
|
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
|
||||||
|
ipcMain.handle('window:setProgressBar', (_event, progress: number, mode: 'normal' | 'paused' = 'normal') => {
|
||||||
|
if (!win) return false
|
||||||
|
const value = Number(progress)
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
win.setProgressBar(-1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
win.setProgressBar(Math.max(0, Math.min(1, value)), { mode })
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
// --- qzplayer IPC Handlers ---
|
// --- qzplayer IPC Handlers ---
|
||||||
ipcMain.handle('qzplayer-command', async (_, command: any[]) => {
|
ipcMain.handle('qzplayer-command', async (_, command: any[]) => {
|
||||||
@@ -102,6 +218,16 @@ ipcMain.handle('plugin:getAll', () => {
|
|||||||
return PluginSystem.getAllPlugins()
|
return PluginSystem.getAllPlugins()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('plugin:getPlaylist', async (_event, pluginId: string, id: string, page = 1, limit = 100) => {
|
||||||
|
const plugin = new PluginSystem(pluginId)
|
||||||
|
return plugin.getPlaylist(id, page, limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('plugin:getAlbum', async (_event, pluginId: string, id: string, page = 1, limit = 100) => {
|
||||||
|
const plugin = new PluginSystem(pluginId)
|
||||||
|
return plugin.getAlbum(id, page, limit)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('plugin:uninstall', (_, id: string) => {
|
ipcMain.handle('plugin:uninstall', (_, id: string) => {
|
||||||
const success = PluginSystem.uninstallPlugin(id)
|
const success = PluginSystem.uninstallPlugin(id)
|
||||||
if (success) notifyPluginsChanged('uninstalled', id)
|
if (success) notifyPluginsChanged('uninstalled', id)
|
||||||
@@ -127,6 +253,159 @@ ipcMain.handle('plugin:install', async () => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auth IPC Handlers
|
||||||
|
ipcMain.handle('auth:getState', () => loadAuthState())
|
||||||
|
ipcMain.handle('auth:getAccessToken', () => getValidAccessToken())
|
||||||
|
ipcMain.handle('listenTogether:getWsUrl', async (_event, params: Record<string, string>) => {
|
||||||
|
const token = await getValidAccessToken()
|
||||||
|
return getListenTogetherWsUrl({ token, ...params })
|
||||||
|
})
|
||||||
|
ipcMain.handle('auth:login', (_event, forcePrompt = false) => openLoginPage(Boolean(forcePrompt)))
|
||||||
|
ipcMain.handle('auth:qr:create', () => createQrLoginSession())
|
||||||
|
ipcMain.handle('auth:qr:poll', async (_event, sessionId: string, pollToken: string) => {
|
||||||
|
const result = await pollQrLoginSession(String(sessionId), String(pollToken))
|
||||||
|
if (result.status === 'success' && result.state) {
|
||||||
|
win?.webContents.send('auth:changed', { status: 'success', state: result.state })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
ipcMain.handle('auth:qr:cancel', (_event, sessionId: string, pollToken: string) => {
|
||||||
|
return cancelQrLoginSession(String(sessionId), String(pollToken))
|
||||||
|
})
|
||||||
|
ipcMain.handle('auth:refresh', () => refreshAuthState())
|
||||||
|
ipcMain.handle('auth:logout', () => {
|
||||||
|
const state = clearAuthState()
|
||||||
|
win?.webContents.send('auth:changed', { status: 'logout', state })
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('privacy:getLibrary', () => getLibraryPrivacy())
|
||||||
|
ipcMain.handle('privacy:setLibrary', (_event, payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => {
|
||||||
|
return setLibraryPrivacy({
|
||||||
|
allow_public_library: typeof payload?.allow_public_library === 'boolean' ? payload.allow_public_library : undefined,
|
||||||
|
allow_public_profile: typeof payload?.allow_public_profile === 'boolean' ? payload.allow_public_profile : undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('user:getProfile', (_event, userId: string) => {
|
||||||
|
return getUserProfile(String(userId || ''))
|
||||||
|
})
|
||||||
|
ipcMain.handle('user:getPlaylists', (_event, userId: string) => {
|
||||||
|
return getUserPublicPlaylists(String(userId || ''))
|
||||||
|
})
|
||||||
|
ipcMain.handle('user:getFavSongs', (_event, userId: string) => {
|
||||||
|
return getUserPublicFavSongs(String(userId || ''))
|
||||||
|
})
|
||||||
|
ipcMain.handle('user:updateProfile', (_event, payload: any) => {
|
||||||
|
return updateCurrentUserProfile(payload || {})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('heartbeat:pc', (_event, duration: number, timestamp?: number) => {
|
||||||
|
return sendPcHeartbeat(duration, timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('stats:listenTime', (_event, detail = 1, userId?: string) => {
|
||||||
|
return getListenTime(Number(detail) || 0, userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('stats:listenRange', (_event, start: string, end: string, userId?: string) => {
|
||||||
|
return getListenTimeRange(String(start), String(end), userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('stats:listenRank', (_event, period: 'week' | 'month' | 'year' = 'week', limit = 200) => {
|
||||||
|
return getListenRank(period, Number(limit) || 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Playlist IPC Handlers
|
||||||
|
ipcMain.handle('playlist:list', async () => {
|
||||||
|
const local = listLocalPlaylists()
|
||||||
|
const cloud = await listCloudPlaylists().catch((err) => {
|
||||||
|
console.error('[Playlist] Failed to load cloud playlists:', err)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return { local, cloud, items: [...local, ...cloud] }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:publicList', (_event, search = '', sort = 'visit', page = 1, limit = 50) => {
|
||||||
|
return listPublicPlaylists(String(search || ''), String(sort || 'visit'), Number(page) || 1, Number(limit) || 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:get', (_event, scope: PlaylistScope, id: string) => {
|
||||||
|
return getPlaylist(scope, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:create', (_event, scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {
|
||||||
|
return createPlaylist(scope, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:update', (_event, scope: PlaylistScope, id: string, info: any) => {
|
||||||
|
return updatePlaylist(scope, id, info)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:delete', (_event, scope: PlaylistScope, id: string) => {
|
||||||
|
return deleteStoredPlaylist(scope, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:addSong', (_event, scope: PlaylistScope, id: string, song: any, index = -1) => {
|
||||||
|
return addSong(scope, id, song, index)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:removeSong', (_event, scope: PlaylistScope, id: string, index: number) => {
|
||||||
|
return removeSong(scope, id, index)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:export', async (_event, scope: PlaylistScope, id: string) => {
|
||||||
|
if (!win) return { success: false, canceled: true }
|
||||||
|
const playlist = await getPlaylist(scope, id)
|
||||||
|
const safeName = (playlist.info.name || 'playlist').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||||
|
const { canceled, filePath } = await dialog.showSaveDialog(win, {
|
||||||
|
title: '导出歌单',
|
||||||
|
defaultPath: `${safeName}.qzplaylist.json`,
|
||||||
|
filters: [
|
||||||
|
{ name: 'QZMusic Playlist', extensions: ['json'] },
|
||||||
|
{ name: 'JSON', extensions: ['json'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (canceled || !filePath) return { success: false, canceled: true }
|
||||||
|
return exportPlaylist(scope, id, filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:import', async () => {
|
||||||
|
if (!win) return { success: false, canceled: true }
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
|
title: '导入歌单',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'QZMusic Playlist', extensions: ['json'] },
|
||||||
|
{ name: 'JSON', extensions: ['json'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (canceled || filePaths.length === 0) return { success: false, canceled: true }
|
||||||
|
const playlist = importPlaylist(filePaths[0])
|
||||||
|
return { success: true, playlist }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:convertScope', (_event, scope: PlaylistScope, id: string, targetScope: PlaylistScope) => {
|
||||||
|
return convertPlaylistScope(scope, id, targetScope)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('playlist:copyToLocal', (_event, scope: PlaylistScope, id: string) => {
|
||||||
|
return copyPlaylistToLocal(scope, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('image:selectAndUpload', async () => {
|
||||||
|
if (!win) return { success: false, canceled: true }
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
|
title: '选择图片',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif'] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (canceled || filePaths.length === 0) return { success: false, canceled: true }
|
||||||
|
return uploadImage(filePaths[0])
|
||||||
|
})
|
||||||
|
|
||||||
// Cache IPC Handlers
|
// Cache IPC Handlers
|
||||||
ipcMain.handle('cache:getInfo', () => {
|
ipcMain.handle('cache:getInfo', () => {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
@@ -165,6 +444,36 @@ ipcMain.handle('dialog:openDirectory', async () => {
|
|||||||
return filePaths[0]
|
return filePaths[0]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:openDirectories', async () => {
|
||||||
|
if (!win) return []
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
|
||||||
|
title: '选择音乐文件夹',
|
||||||
|
properties: ['openDirectory', 'multiSelections', 'createDirectory']
|
||||||
|
})
|
||||||
|
if (canceled || filePaths.length === 0) return []
|
||||||
|
return filePaths
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('localMusic:getLibrary', () => {
|
||||||
|
return getLocalMusicLibrary()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('localMusic:scan', async (_event, roots: string[]) => {
|
||||||
|
return scanLocalMusic(Array.isArray(roots) ? roots : [])
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('localMusic:setRoots', (_event, roots: string[]) => {
|
||||||
|
return setLocalMusicRoots(Array.isArray(roots) ? roots : [])
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('localMusic:remove', (_event, id: string) => {
|
||||||
|
return removeLocalSong(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('localMusic:clearMissing', () => {
|
||||||
|
return clearMissingLocalSongs()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('cache:changeLocation', async (_, newPath: string) => {
|
ipcMain.handle('cache:changeLocation', async (_, newPath: string) => {
|
||||||
try {
|
try {
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -299,6 +608,9 @@ app.whenReady().then(() => {
|
|||||||
Menu.setApplicationMenu(null)
|
Menu.setApplicationMenu(null)
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
const callbackUrl = process.argv.find((arg) => arg.startsWith('qzmusic://auth_result'))
|
||||||
|
if (callbackUrl) handleAuthCallback(callbackUrl)
|
||||||
|
|
||||||
// Start Proxy Server
|
// Start Proxy Server
|
||||||
startProxyServer()
|
startProxyServer()
|
||||||
|
|
||||||
|
|||||||
235
src/main/localMusicStore.ts
Normal file
235
src/main/localMusicStore.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
|
const AUDIO_EXTENSIONS = new Set([
|
||||||
|
'.mp3',
|
||||||
|
'.flac',
|
||||||
|
'.m4a',
|
||||||
|
'.mp4',
|
||||||
|
'.aac',
|
||||||
|
'.ogg',
|
||||||
|
'.opus',
|
||||||
|
'.wav',
|
||||||
|
'.aiff',
|
||||||
|
'.aif',
|
||||||
|
'.ape',
|
||||||
|
'.wv',
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface LocalSong {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
artist: string
|
||||||
|
albumName: string
|
||||||
|
duration: string
|
||||||
|
durationSeconds: number
|
||||||
|
source: 'local'
|
||||||
|
type: 'Local'
|
||||||
|
url: string
|
||||||
|
picUrl: string
|
||||||
|
lyric: string
|
||||||
|
quality: string
|
||||||
|
bitrate: number
|
||||||
|
sampleRate: number
|
||||||
|
channels: number
|
||||||
|
size: number
|
||||||
|
modifiedAt: number
|
||||||
|
addedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalMusicLibrary {
|
||||||
|
roots: string[]
|
||||||
|
songs: LocalSong[]
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLibraryPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'local-music.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReaderExe(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.env.APP_ROOT || '', 'native', 'taglib_reader', 'build', 'taglib_reader_cli.exe'),
|
||||||
|
path.join(process.resourcesPath || '', 'native', 'taglib_reader_cli.exe'),
|
||||||
|
]
|
||||||
|
const target = candidates.find((candidate) => candidate && fs.existsSync(candidate))
|
||||||
|
if (!target) throw new Error('TagLib reader executable not found')
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArtworkDir(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'local-music-artwork')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultRoots(): string[] {
|
||||||
|
const username = path.basename(app.getPath('home'))
|
||||||
|
const roots: string[] = []
|
||||||
|
for (let code = 67; code <= 90; code++) {
|
||||||
|
const drive = `${String.fromCharCode(code)}:\\`
|
||||||
|
if (!fs.existsSync(drive)) continue
|
||||||
|
const userRoot = path.join(drive, 'Users', username)
|
||||||
|
for (const dirName of ['Music', '音乐', 'Downloads', '下载']) {
|
||||||
|
const candidate = path.join(userRoot, dirName)
|
||||||
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) roots.push(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(new Set(roots))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLibrary(): LocalMusicLibrary {
|
||||||
|
const file = getLibraryPath()
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as LocalMusicLibrary
|
||||||
|
let changed = false
|
||||||
|
const songs = Array.isArray(parsed.songs)
|
||||||
|
? parsed.songs
|
||||||
|
.filter((song) => {
|
||||||
|
const exists = fs.existsSync(song.path)
|
||||||
|
if (!exists) changed = true
|
||||||
|
return exists
|
||||||
|
})
|
||||||
|
.map((song) => {
|
||||||
|
if (!song.picUrl || !song.picUrl.startsWith('data:')) return song
|
||||||
|
changed = true
|
||||||
|
return { ...song, picUrl: '' }
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
const library = {
|
||||||
|
roots: Array.isArray(parsed.roots) ? parsed.roots : [],
|
||||||
|
songs,
|
||||||
|
updatedAt: Number(parsed.updatedAt) || 0,
|
||||||
|
}
|
||||||
|
if (changed || songs.length !== parsed.songs?.length) saveLibrary(library)
|
||||||
|
return library
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LocalMusic] Failed to load library:', error)
|
||||||
|
}
|
||||||
|
return { roots: getDefaultRoots(), songs: [], updatedAt: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLibrary(library: LocalMusicLibrary): LocalMusicLibrary {
|
||||||
|
fs.writeFileSync(getLibraryPath(), JSON.stringify(library, null, 2), 'utf8')
|
||||||
|
return library
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathUnderRoots(filePath: string, roots: string[]): boolean {
|
||||||
|
const normalizedFile = path.resolve(filePath).toLowerCase()
|
||||||
|
return roots.some((root) => {
|
||||||
|
const normalizedRoot = path.resolve(root).toLowerCase()
|
||||||
|
return normalizedFile === normalizedRoot || normalizedFile.startsWith(`${normalizedRoot}${path.sep}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackName(filePath: string): string {
|
||||||
|
return path.basename(filePath, path.extname(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalSong(filePath: string, metadata: any): LocalSong {
|
||||||
|
const stat = fs.statSync(filePath)
|
||||||
|
const title = String(metadata.title || '').trim()
|
||||||
|
const artist = String(metadata.artist || '').trim()
|
||||||
|
const album = String(metadata.album || '').trim()
|
||||||
|
return {
|
||||||
|
id: Buffer.from(filePath, 'utf8').toString('base64url'),
|
||||||
|
path: filePath,
|
||||||
|
name: title || fallbackName(filePath),
|
||||||
|
artist: artist || '未知艺术家',
|
||||||
|
albumName: album || '未知专辑',
|
||||||
|
duration: String(metadata.duration || '00:00'),
|
||||||
|
durationSeconds: Number(metadata.durationSeconds) || 0,
|
||||||
|
source: 'local',
|
||||||
|
type: 'Local',
|
||||||
|
url: filePath,
|
||||||
|
picUrl: metadata.coverPath ? pathToFileURL(String(metadata.coverPath)).toString() : '',
|
||||||
|
lyric: String(metadata.lyric || '').trim(),
|
||||||
|
quality: String(metadata.quality || ''),
|
||||||
|
bitrate: Number(metadata.bitrate) || 0,
|
||||||
|
sampleRate: Number(metadata.sampleRate) || 0,
|
||||||
|
channels: Number(metadata.channels) || 0,
|
||||||
|
size: stat.size,
|
||||||
|
modifiedAt: stat.mtimeMs,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalMusicLibrary(): LocalMusicLibrary {
|
||||||
|
return loadLibrary()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocalMusicRoots(roots: string[]): LocalMusicLibrary {
|
||||||
|
const library = loadLibrary()
|
||||||
|
const normalizedRoots = Array.from(new Set(
|
||||||
|
roots
|
||||||
|
.map((root) => path.resolve(root))
|
||||||
|
.filter((root) => fs.existsSync(root) && fs.statSync(root).isDirectory())
|
||||||
|
))
|
||||||
|
return saveLibrary({
|
||||||
|
...library,
|
||||||
|
roots: normalizedRoots,
|
||||||
|
songs: library.songs.filter((song) => fs.existsSync(song.path) && isPathUnderRoots(song.path, normalizedRoots)),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanLocalMusic(roots: string[]): Promise<LocalMusicLibrary> {
|
||||||
|
const normalizedRoots = Array.from(new Set(
|
||||||
|
roots
|
||||||
|
.map((root) => path.resolve(root))
|
||||||
|
.filter((root) => fs.existsSync(root) && fs.statSync(root).isDirectory())
|
||||||
|
))
|
||||||
|
const reader = getReaderExe()
|
||||||
|
const artworkDir = getArtworkDir()
|
||||||
|
fs.mkdirSync(artworkDir, { recursive: true })
|
||||||
|
const { stdout, stderr } = await execFileAsync(reader, ['scan', '--artwork-dir', artworkDir, ...normalizedRoots], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: 15 * 60 * 1000,
|
||||||
|
})
|
||||||
|
if (stderr?.trim()) console.warn('[LocalMusic] Scanner warnings:', stderr)
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stdout) as { songs?: any[] }
|
||||||
|
const songs: LocalSong[] = []
|
||||||
|
for (const metadata of parsed.songs || []) {
|
||||||
|
const filePath = String(metadata?.path || '')
|
||||||
|
if (!filePath || !fs.existsSync(filePath) || !AUDIO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) continue
|
||||||
|
try {
|
||||||
|
songs.push(createLocalSong(filePath, metadata))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[LocalMusic] Failed to normalize tags:', filePath, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveLibrary({
|
||||||
|
roots: normalizedRoots,
|
||||||
|
songs,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLocalSong(id: string): LocalMusicLibrary {
|
||||||
|
const library = loadLibrary()
|
||||||
|
return saveLibrary({
|
||||||
|
...library,
|
||||||
|
songs: library.songs.filter((song) => song.id !== id),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMissingLocalSongs(): LocalMusicLibrary {
|
||||||
|
const library = loadLibrary()
|
||||||
|
return saveLibrary({
|
||||||
|
...library,
|
||||||
|
songs: library.songs.filter((song) => fs.existsSync(song.path)),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
412
src/main/playlistStore.ts
Normal file
412
src/main/playlistStore.ts
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { loadAuthState, qzFetch } from './authStore'
|
||||||
|
|
||||||
|
export type PlaylistScope = 'local' | 'cloud'
|
||||||
|
|
||||||
|
export interface PlaylistSong {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
artist?: string
|
||||||
|
artists?: string
|
||||||
|
source: string
|
||||||
|
picUrl?: string
|
||||||
|
pic?: string
|
||||||
|
mPic?: string
|
||||||
|
sPic?: string
|
||||||
|
albumName?: string | null
|
||||||
|
albumId?: string | null
|
||||||
|
duration?: string
|
||||||
|
interval?: string
|
||||||
|
url?: string
|
||||||
|
type?: string
|
||||||
|
qualities?: Record<string, string>
|
||||||
|
types?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
img: string
|
||||||
|
cover_mode?: 'auto' | 'custom' | string
|
||||||
|
author?: string
|
||||||
|
play_count?: string
|
||||||
|
visit_count?: number
|
||||||
|
is_public?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppPlaylist {
|
||||||
|
id: string
|
||||||
|
scope: PlaylistScope
|
||||||
|
source: PlaylistScope
|
||||||
|
info: PlaylistInfo
|
||||||
|
list: PlaylistSong[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalPlaylistFile = Omit<AppPlaylist, 'scope' | 'source' | 'total'> & {
|
||||||
|
id: string
|
||||||
|
list: PlaylistSong[]
|
||||||
|
info: PlaylistInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaylistsDir(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'playlists')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalPlaylistPath(id: string): string {
|
||||||
|
return path.join(getPlaylistsDir(), `${id}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePlaylistsDir(): void {
|
||||||
|
fs.mkdirSync(getPlaylistsDir(), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLocalId(id: string): void {
|
||||||
|
if (!/^[a-z0-9._-]+$/i.test(id)) throw new Error('Invalid playlist id')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSong(song: any): PlaylistSong {
|
||||||
|
const id = String(song?.id ?? song?.songmid ?? song?.songId ?? '')
|
||||||
|
const artist = song?.artist ?? song?.artists ?? song?.singer ?? ''
|
||||||
|
const pic = song?.picUrl ?? song?.pic ?? song?.mPic ?? song?.img ?? ''
|
||||||
|
const types = song?.types ?? song?.qualities ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...song,
|
||||||
|
id,
|
||||||
|
name: String(song?.name ?? ''),
|
||||||
|
artist: Array.isArray(artist) ? artist.join('、') : String(artist),
|
||||||
|
artists: Array.isArray(artist) ? artist.join('、') : String(artist),
|
||||||
|
source: String(song?.source ?? 'local'),
|
||||||
|
picUrl: pic,
|
||||||
|
pic,
|
||||||
|
mPic: song?.mPic ?? song?.m_img ?? pic,
|
||||||
|
sPic: song?.sPic ?? song?.s_img ?? pic,
|
||||||
|
interval: String(song?.interval ?? song?.duration ?? ''),
|
||||||
|
duration: String(song?.duration ?? song?.interval ?? ''),
|
||||||
|
type: song?.type ?? (song?.source === 'local' ? 'Local' : 'Remote'),
|
||||||
|
qualities: types,
|
||||||
|
types,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocalPlaylist(raw: LocalPlaylistFile): AppPlaylist {
|
||||||
|
const list = Array.isArray(raw.list) ? raw.list.map(normalizeSong) : []
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
scope: 'local',
|
||||||
|
source: 'local',
|
||||||
|
info: {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.info?.name || '新建歌单',
|
||||||
|
desc: raw.info?.desc || '',
|
||||||
|
img: raw.info?.img || list[0]?.picUrl || '',
|
||||||
|
cover_mode: raw.info?.cover_mode || 'auto',
|
||||||
|
author: raw.info?.author || '本地',
|
||||||
|
play_count: raw.info?.play_count || '',
|
||||||
|
visit_count: Number(raw.info?.visit_count ?? raw.info?.play_count ?? 0) || 0,
|
||||||
|
is_public: Boolean(raw.info?.is_public),
|
||||||
|
},
|
||||||
|
list,
|
||||||
|
total: list.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudPlaylist(raw: any): AppPlaylist {
|
||||||
|
const info = raw?.info ?? raw
|
||||||
|
const list = Array.isArray(raw?.list) ? raw.list.map(normalizeSong) : []
|
||||||
|
const id = String(info?.id ?? raw?.id ?? '')
|
||||||
|
const total = Number(raw?.total ?? info?.total ?? list.length) || list.length
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
scope: 'cloud',
|
||||||
|
source: 'cloud',
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
name: info?.name || '云端歌单',
|
||||||
|
desc: info?.desc || '',
|
||||||
|
img: info?.img || info?.pic || list[0]?.picUrl || '',
|
||||||
|
cover_mode: info?.cover_mode || info?.coverMode || 'auto',
|
||||||
|
author: info?.author || '',
|
||||||
|
play_count: info?.play_count || '',
|
||||||
|
visit_count: Number(info?.visit_count ?? info?.play_count ?? 0) || 0,
|
||||||
|
is_public: Boolean(info?.is_public ?? info?.public ?? false),
|
||||||
|
},
|
||||||
|
list,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalPlaylist(id: string): AppPlaylist {
|
||||||
|
assertLocalId(id)
|
||||||
|
const raw = JSON.parse(fs.readFileSync(getLocalPlaylistPath(id), 'utf8')) as LocalPlaylistFile
|
||||||
|
return normalizeLocalPlaylist(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocalPlaylist(playlist: AppPlaylist): AppPlaylist {
|
||||||
|
ensurePlaylistsDir()
|
||||||
|
assertLocalId(playlist.id)
|
||||||
|
const normalized = normalizeLocalPlaylist({
|
||||||
|
id: playlist.id,
|
||||||
|
info: playlist.info,
|
||||||
|
list: playlist.list,
|
||||||
|
})
|
||||||
|
fs.writeFileSync(getLocalPlaylistPath(playlist.id), JSON.stringify({
|
||||||
|
id: normalized.id,
|
||||||
|
info: normalized.info,
|
||||||
|
list: normalized.list,
|
||||||
|
}, null, 2), 'utf8')
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalPlaylistCopy(playlist: AppPlaylist): AppPlaylist {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
return writeLocalPlaylist({
|
||||||
|
...playlist,
|
||||||
|
id,
|
||||||
|
scope: 'local',
|
||||||
|
source: 'local',
|
||||||
|
info: {
|
||||||
|
...playlist.info,
|
||||||
|
id,
|
||||||
|
author: '本地',
|
||||||
|
},
|
||||||
|
list: playlist.list.map(normalizeSong),
|
||||||
|
total: playlist.list.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCloudPlaylistCopy(playlist: AppPlaylist): Promise<AppPlaylist> {
|
||||||
|
const list = playlist.list.map(normalizeSong)
|
||||||
|
const info = {
|
||||||
|
...playlist.info,
|
||||||
|
id: '',
|
||||||
|
author: '',
|
||||||
|
is_public: Boolean(playlist.info.is_public),
|
||||||
|
}
|
||||||
|
const result = await qzFetch('/playlist/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ info, list }),
|
||||||
|
})
|
||||||
|
if (result?.status !== 'success' || !result.id) {
|
||||||
|
throw new Error(result?.message || 'Create cloud playlist failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(result.id)
|
||||||
|
let created = await getPlaylist('cloud', id)
|
||||||
|
if ((created.list?.length || 0) === 0 && list.length > 0) {
|
||||||
|
for (const song of list) {
|
||||||
|
const addResult = await qzFetch(`/playlist/${encodeURIComponent(id)}/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ data: song, index: -1 }),
|
||||||
|
})
|
||||||
|
if (addResult?.status !== 'success') throw new Error(addResult?.message || 'Add song failed')
|
||||||
|
}
|
||||||
|
created = await getPlaylist('cloud', id)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImportedPlaylist(raw: any): AppPlaylist {
|
||||||
|
const payload = raw?.type === 'qzmusic-playlist' ? raw : raw?.playlist ? raw.playlist : raw
|
||||||
|
const rawInfo = payload?.info ?? payload
|
||||||
|
const list = Array.isArray(payload?.list) ? payload.list.map(normalizeSong) : []
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
return normalizeLocalPlaylist({
|
||||||
|
id,
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
name: rawInfo?.name || '导入的歌单',
|
||||||
|
desc: rawInfo?.desc || '',
|
||||||
|
img: rawInfo?.img || rawInfo?.pic || list[0]?.picUrl || '',
|
||||||
|
cover_mode: rawInfo?.cover_mode || rawInfo?.coverMode || 'auto',
|
||||||
|
author: '本地',
|
||||||
|
play_count: '',
|
||||||
|
visit_count: 0,
|
||||||
|
},
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLocalPlaylists(): AppPlaylist[] {
|
||||||
|
ensurePlaylistsDir()
|
||||||
|
return fs.readdirSync(getPlaylistsDir(), { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||||
|
.map((entry) => {
|
||||||
|
try {
|
||||||
|
return readLocalPlaylist(path.basename(entry.name, '.json'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Playlist] Failed to read local playlist:', entry.name, err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((playlist): playlist is AppPlaylist => playlist !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCloudPlaylists(): Promise<AppPlaylist[]> {
|
||||||
|
const userId = loadAuthState().userInfo?.id
|
||||||
|
if (!userId) return []
|
||||||
|
const raw = await qzFetch(`/user/${encodeURIComponent(userId)}/playlists`)
|
||||||
|
return Array.isArray(raw) ? raw.map(normalizeCloudPlaylist) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPublicPlaylists(
|
||||||
|
search = '',
|
||||||
|
sort = 'visit',
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<{ items: AppPlaylist[]; total: number; page: number; limit: number; sort: string }> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: String(Math.max(1, Number(page) || 1)),
|
||||||
|
limit: String(Math.max(1, Math.min(50, Number(limit) || 50))),
|
||||||
|
sort: ['visit', 'name', 'total'].includes(sort) ? sort : 'visit',
|
||||||
|
})
|
||||||
|
if (search.trim()) query.set('search', search.trim())
|
||||||
|
const raw = await qzFetch(`/playlist/public?${query.toString()}`)
|
||||||
|
const items = Array.isArray(raw?.items) ? raw.items.map(normalizeCloudPlaylist) : []
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: Number(raw?.total ?? items.length) || items.length,
|
||||||
|
page: Number(raw?.page ?? page) || page,
|
||||||
|
limit: Number(raw?.limit ?? limit) || limit,
|
||||||
|
sort: String(raw?.sort ?? sort),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylist(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
|
||||||
|
if (scope === 'local') return readLocalPlaylist(id)
|
||||||
|
const raw = await qzFetch(`/playlist/${encodeURIComponent(id)}`)
|
||||||
|
return normalizeCloudPlaylist(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlaylist(scope: PlaylistScope, data: { name: string; desc?: string; is_public?: boolean }): Promise<AppPlaylist> {
|
||||||
|
const id = scope === 'local' ? crypto.randomUUID() : ''
|
||||||
|
const playlist: AppPlaylist = {
|
||||||
|
id,
|
||||||
|
scope,
|
||||||
|
source: scope,
|
||||||
|
info: {
|
||||||
|
id,
|
||||||
|
name: data.name.trim() || '新建歌单',
|
||||||
|
desc: data.desc?.trim() || '',
|
||||||
|
img: '',
|
||||||
|
author: scope === 'local' ? '本地' : '',
|
||||||
|
play_count: '',
|
||||||
|
visit_count: 0,
|
||||||
|
is_public: scope === 'cloud' ? Boolean(data.is_public) : false,
|
||||||
|
},
|
||||||
|
list: [],
|
||||||
|
total: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scope === 'local') return writeLocalPlaylist(playlist)
|
||||||
|
|
||||||
|
const result = await qzFetch('/playlist/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ info: playlist.info, list: [] }),
|
||||||
|
})
|
||||||
|
if (result?.status !== 'success' || !result.id) {
|
||||||
|
throw new Error(result?.message || 'Create cloud playlist failed')
|
||||||
|
}
|
||||||
|
return getPlaylist('cloud', String(result.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlaylist(scope: PlaylistScope, id: string, info: Partial<PlaylistInfo>): Promise<AppPlaylist> {
|
||||||
|
if (scope === 'local') {
|
||||||
|
const playlist = readLocalPlaylist(id)
|
||||||
|
playlist.info = { ...playlist.info, ...info, id }
|
||||||
|
return writeLocalPlaylist(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(info),
|
||||||
|
})
|
||||||
|
if (result?.status !== 'success') throw new Error(result?.message || 'Update cloud playlist failed')
|
||||||
|
return getPlaylist('cloud', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPlaylistToLocal(scope: PlaylistScope, id: string): Promise<AppPlaylist> {
|
||||||
|
const playlist = await getPlaylist(scope, id)
|
||||||
|
return createLocalPlaylistCopy(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlaylist(scope: PlaylistScope, id: string): Promise<{ success: boolean }> {
|
||||||
|
if (scope === 'local') {
|
||||||
|
assertLocalId(id)
|
||||||
|
const target = getLocalPlaylistPath(id)
|
||||||
|
if (fs.existsSync(target)) fs.unlinkSync(target)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
return { success: result?.status === 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSong(scope: PlaylistScope, id: string, song: PlaylistSong, index = -1): Promise<AppPlaylist> {
|
||||||
|
const normalizedSong = normalizeSong(song)
|
||||||
|
if (scope === 'local') {
|
||||||
|
const playlist = readLocalPlaylist(id)
|
||||||
|
const exists = playlist.list.some((item) => item.id === normalizedSong.id && item.source === normalizedSong.source)
|
||||||
|
if (!exists) {
|
||||||
|
if (index >= 0 && index <= playlist.list.length) playlist.list.splice(index, 0, normalizedSong)
|
||||||
|
else playlist.list.push(normalizedSong)
|
||||||
|
}
|
||||||
|
playlist.info.img = playlist.info.img || normalizedSong.picUrl || ''
|
||||||
|
return writeLocalPlaylist(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ data: normalizedSong, index }),
|
||||||
|
})
|
||||||
|
if (result?.status !== 'success') throw new Error(result?.message || 'Add song failed')
|
||||||
|
return getPlaylist('cloud', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSong(scope: PlaylistScope, id: string, index: number): Promise<AppPlaylist> {
|
||||||
|
if (scope === 'local') {
|
||||||
|
const playlist = readLocalPlaylist(id)
|
||||||
|
if (index >= 0 && index < playlist.list.length) playlist.list.splice(index, 1)
|
||||||
|
playlist.info.img = playlist.list[0]?.picUrl || ''
|
||||||
|
return writeLocalPlaylist(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await qzFetch(`/playlist/${encodeURIComponent(id)}/list/${index}`, { method: 'DELETE' })
|
||||||
|
if (result?.status !== 'success') throw new Error(result?.message || 'Remove song failed')
|
||||||
|
return getPlaylist('cloud', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportPlaylist(scope: PlaylistScope, id: string, filePath: string): Promise<{ success: boolean; path: string }> {
|
||||||
|
const playlist = await getPlaylist(scope, id)
|
||||||
|
const payload = {
|
||||||
|
type: 'qzmusic-playlist',
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
info: playlist.info,
|
||||||
|
list: playlist.list.map(normalizeSong),
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8')
|
||||||
|
return { success: true, path: filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importPlaylist(filePath: string): AppPlaylist {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
return writeLocalPlaylist(normalizeImportedPlaylist(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertPlaylistScope(scope: PlaylistScope, id: string, targetScope: PlaylistScope): Promise<AppPlaylist> {
|
||||||
|
if (scope === targetScope) return getPlaylist(scope, id)
|
||||||
|
const playlist = await getPlaylist(scope, id)
|
||||||
|
const converted = targetScope === 'local'
|
||||||
|
? createLocalPlaylistCopy(playlist)
|
||||||
|
: await createCloudPlaylistCopy(playlist)
|
||||||
|
|
||||||
|
const deleted = await deletePlaylist(scope, id)
|
||||||
|
if (!deleted.success) throw new Error('Converted playlist, but failed to delete source playlist')
|
||||||
|
return converted
|
||||||
|
}
|
||||||
@@ -30,10 +30,15 @@ type PluginModule = Record<string, any> & {
|
|||||||
info?: PluginInfo['info']
|
info?: PluginInfo['info']
|
||||||
getUrl?: (...args: any[]) => any
|
getUrl?: (...args: any[]) => any
|
||||||
getLyric?: (...args: any[]) => any
|
getLyric?: (...args: any[]) => any
|
||||||
|
getPlaylist?: (...args: any[]) => any
|
||||||
|
getPlayList?: (...args: any[]) => any
|
||||||
|
getAlbum?: (...args: any[]) => any
|
||||||
musicSearch?: {
|
musicSearch?: {
|
||||||
search?: (...args: any[]) => any
|
search?: (...args: any[]) => any
|
||||||
} | ((...args: any[]) => any)
|
} | ((...args: any[]) => any)
|
||||||
search?: (...args: any[]) => any
|
search?: (...args: any[]) => any
|
||||||
|
songList?: Record<string, any>
|
||||||
|
album?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModuleCacheEntry = {
|
type ModuleCacheEntry = {
|
||||||
@@ -171,6 +176,80 @@ function normalizeSearchResult(result: any, pluginId: string): any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDuration(value: any): string {
|
||||||
|
if (typeof value === 'string' && /^\d{1,3}:\d{2}$/.test(value)) return value
|
||||||
|
const milliseconds = Number(value)
|
||||||
|
return Number.isFinite(milliseconds) ? formatDuration(milliseconds) : '00:00'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSongForApp(item: any, pluginId: string): any {
|
||||||
|
const normalized = normalizeSearchItem(item, pluginId)
|
||||||
|
const picUrl = normalized.m_img || normalized.mPic || normalized.pic || normalized.img || normalized.picUrl || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
id: String(normalized.id || normalized.songmid || ''),
|
||||||
|
name: String(normalized.name || ''),
|
||||||
|
artist: String(normalized.artists || normalized.singer || normalized.artist || ''),
|
||||||
|
picUrl,
|
||||||
|
url: '',
|
||||||
|
duration: normalizeDuration(normalized.interval ?? normalized.duration ?? normalized.dt),
|
||||||
|
source: String(normalized.source || pluginId),
|
||||||
|
albumId: normalized.albumId ? String(normalized.albumId) : null,
|
||||||
|
albumName: normalized.albumName || '',
|
||||||
|
type: 'Remote',
|
||||||
|
quality: 'auto',
|
||||||
|
types: normalized.types ?? normalized.qualities ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginCollection(result: any, pluginId: string, id: string, kind: 'playlist' | 'album'): any {
|
||||||
|
const rawList = Array.isArray(result) ? result : result?.list
|
||||||
|
const list = Array.isArray(rawList)
|
||||||
|
? rawList.filter(Boolean).map((item) => normalizeSongForApp(item, pluginId))
|
||||||
|
: []
|
||||||
|
const info = result?.info ?? result ?? {}
|
||||||
|
const title = info.name || info.title || (kind === 'album' ? '插件专辑' : '插件歌单')
|
||||||
|
const desc = info.desc || info.description || ''
|
||||||
|
const img = info.img || info.pic || info.picUrl || info.cover || list[0]?.picUrl || ''
|
||||||
|
const author = info.author || info.artist || info.creator || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(info.id ?? id),
|
||||||
|
scope: 'plugin',
|
||||||
|
source: pluginId,
|
||||||
|
kind,
|
||||||
|
info: {
|
||||||
|
id: String(info.id ?? id),
|
||||||
|
name: String(title),
|
||||||
|
desc: String(desc),
|
||||||
|
img: String(img),
|
||||||
|
author: String(author),
|
||||||
|
play_count: String(info.play_count || info.playCount || ''),
|
||||||
|
},
|
||||||
|
list,
|
||||||
|
total: Number(result?.total ?? info.total ?? list.length) || list.length,
|
||||||
|
page: Number(result?.page ?? 1) || 1,
|
||||||
|
limit: Number(result?.limit ?? list.length) || list.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callCandidate(candidates: Array<{ target: any; method: string; args: any[] }>): Promise<any> {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const fn = candidate.target?.[candidate.method]
|
||||||
|
if (typeof fn !== 'function') continue
|
||||||
|
return await unwrapPluginResult(fn.apply(candidate.target, candidate.args))
|
||||||
|
}
|
||||||
|
throw new Error('Method not found')
|
||||||
|
}
|
||||||
|
|
||||||
export class PluginSystem {
|
export class PluginSystem {
|
||||||
private readonly pluginId: string
|
private readonly pluginId: string
|
||||||
private plugin: PluginModule | null = null
|
private plugin: PluginModule | null = null
|
||||||
@@ -210,6 +289,12 @@ export class PluginSystem {
|
|||||||
if (method === 'getUrl') {
|
if (method === 'getUrl') {
|
||||||
return this.getUrl(String(args[0] ?? ''), String(args[1] ?? ''))
|
return this.getUrl(String(args[0] ?? ''), String(args[1] ?? ''))
|
||||||
}
|
}
|
||||||
|
if (method === 'getPlaylist') {
|
||||||
|
return this.getPlaylist(String(args[0] ?? ''), Number(args[1]) || 1, Number(args[2]) || 100)
|
||||||
|
}
|
||||||
|
if (method === 'getAlbum') {
|
||||||
|
return this.getAlbum(String(args[0] ?? ''), Number(args[1]) || 1, Number(args[2]) || 100)
|
||||||
|
}
|
||||||
|
|
||||||
const plugin = this.getRequiredPlugin()
|
const plugin = this.getRequiredPlugin()
|
||||||
const target = plugin[method]
|
const target = plugin[method]
|
||||||
@@ -290,6 +375,29 @@ export class PluginSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPlaylist(id: string, page = 1, limit = 100): Promise<any> {
|
||||||
|
const plugin = this.getRequiredPlugin()
|
||||||
|
const result = await callCandidate([
|
||||||
|
{ target: plugin, method: 'getPlaylist', args: [id, page, limit] },
|
||||||
|
{ target: plugin, method: 'getPlayList', args: [id, page, limit] },
|
||||||
|
{ target: plugin, method: 'getMusicSheet', args: [id, page, limit] },
|
||||||
|
{ target: plugin.songList, method: 'getListDetail', args: [id, page, limit] },
|
||||||
|
{ target: plugin.songList, method: 'getPlaylistDetail', args: [id, page, limit] },
|
||||||
|
])
|
||||||
|
return normalizePluginCollection(result, this.pluginId, id, 'playlist')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbum(id: string, page = 1, limit = 100): Promise<any> {
|
||||||
|
const plugin = this.getRequiredPlugin()
|
||||||
|
const result = await callCandidate([
|
||||||
|
{ target: plugin, method: 'getAlbum', args: [id, page, limit] },
|
||||||
|
{ target: plugin, method: 'getMusicAlbum', args: [id, page, limit] },
|
||||||
|
{ target: plugin.album, method: 'getListDetail', args: [id, page, limit] },
|
||||||
|
{ target: plugin.songList, method: 'getAlbumDetail', args: [id, page, limit] },
|
||||||
|
])
|
||||||
|
return normalizePluginCollection(result, this.pluginId, id, 'album')
|
||||||
|
}
|
||||||
|
|
||||||
static getAllPlugins(): any[] {
|
static getAllPlugins(): any[] {
|
||||||
const pluginsPath = getPluginsPath()
|
const pluginsPath = getPluginsPath()
|
||||||
if (!fs.existsSync(pluginsPath)) return []
|
if (!fs.existsSync(pluginsPath)) return []
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ export interface AppSettings {
|
|||||||
// Appearance
|
// Appearance
|
||||||
theme: 'dark' | 'light';
|
theme: 'dark' | 'light';
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
|
// Playlist
|
||||||
|
playlistPagingMode: 'infinite' | 'pagination';
|
||||||
|
openPlayerOnSongClick: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
persistCache: true,
|
persistCache: true,
|
||||||
cachePath: path.join(app.getPath('userData'), 'cache'), // Default
|
cachePath: path.join(app.getPath('userData'), 'cache'), // Default
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
accentColor: '#ec4141', // Default red
|
accentColor: '#8289d3',
|
||||||
|
playlistPagingMode: 'infinite',
|
||||||
|
openPlayerOnSongClick: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let settingsCache: AppSettings | null = null;
|
let settingsCache: AppSettings | null = null;
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
const toPlainStringArray = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.map((item) => String(item)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCloneableObject = (value: unknown): any => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value ?? {}))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||||
|
setTaskbarProgress: (progress: number, mode: 'normal' | 'paused' = 'normal') => ipcRenderer.invoke('window:setProgressBar', progress, mode),
|
||||||
|
|
||||||
// qzplayer Control
|
// qzplayer Control
|
||||||
qzplayer: {
|
qzplayer: {
|
||||||
@@ -24,6 +38,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
|
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args),
|
||||||
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
|
search: (pluginId: string, query: string, page: number, limit: number) => ipcRenderer.invoke('plugin:call', pluginId, 'search', [query, page, limit]),
|
||||||
getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]),
|
getLyric: (pluginId: string, id: string) => ipcRenderer.invoke('plugin:call', pluginId, 'getLyric', [id]),
|
||||||
|
getPlaylist: (pluginId: string, id: string, page = 1, limit = 100) => ipcRenderer.invoke('plugin:getPlaylist', pluginId, id, page, limit),
|
||||||
|
getAlbum: (pluginId: string, id: string, page = 1, limit = 100) => ipcRenderer.invoke('plugin:getAlbum', pluginId, id, page, limit),
|
||||||
getAll: () => ipcRenderer.invoke('plugin:getAll'),
|
getAll: () => ipcRenderer.invoke('plugin:getAll'),
|
||||||
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
|
uninstall: (id: string) => ipcRenderer.invoke('plugin:uninstall', id),
|
||||||
install: () => ipcRenderer.invoke('plugin:install'),
|
install: () => ipcRenderer.invoke('plugin:install'),
|
||||||
@@ -37,6 +53,67 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
getState: () => ipcRenderer.invoke('auth:getState'),
|
||||||
|
getAccessToken: () => ipcRenderer.invoke('auth:getAccessToken'),
|
||||||
|
login: (forcePrompt = false) => ipcRenderer.invoke('auth:login', forcePrompt),
|
||||||
|
qrCreate: () => ipcRenderer.invoke('auth:qr:create'),
|
||||||
|
qrPoll: (sessionId: string, pollToken: string) => ipcRenderer.invoke('auth:qr:poll', sessionId, pollToken),
|
||||||
|
qrCancel: (sessionId: string, pollToken: string) => ipcRenderer.invoke('auth:qr:cancel', sessionId, pollToken),
|
||||||
|
refresh: () => ipcRenderer.invoke('auth:refresh'),
|
||||||
|
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||||
|
onChanged: (callback: (payload: any) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, payload: any) => callback(payload)
|
||||||
|
ipcRenderer.on('auth:changed', listener)
|
||||||
|
return () => ipcRenderer.removeListener('auth:changed', listener)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
listenTogether: {
|
||||||
|
getWsUrl: (params: Record<string, string>) => ipcRenderer.invoke('listenTogether:getWsUrl', params),
|
||||||
|
},
|
||||||
|
|
||||||
|
heartbeat: {
|
||||||
|
sendPc: (duration: number, timestamp?: number) => ipcRenderer.invoke('heartbeat:pc', duration, timestamp),
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: {
|
||||||
|
getListenTime: (detail = 1, userId?: string) => ipcRenderer.invoke('stats:listenTime', detail, userId),
|
||||||
|
getListenRange: (start: string, end: string, userId?: string) => ipcRenderer.invoke('stats:listenRange', start, end, userId),
|
||||||
|
getListenRank: (period: 'week' | 'month' | 'year' = 'week', limit = 200) => ipcRenderer.invoke('stats:listenRank', period, limit),
|
||||||
|
},
|
||||||
|
|
||||||
|
playlist: {
|
||||||
|
list: () => ipcRenderer.invoke('playlist:list'),
|
||||||
|
publicList: (search = '', sort = 'visit', page = 1, limit = 50) => ipcRenderer.invoke('playlist:publicList', search, sort, page, limit),
|
||||||
|
get: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:get', scope, id),
|
||||||
|
create: (scope: 'local' | 'cloud', data: { name: string; desc?: string; is_public?: boolean }) => ipcRenderer.invoke('playlist:create', scope, data),
|
||||||
|
update: (scope: 'local' | 'cloud', id: string, info: any) => ipcRenderer.invoke('playlist:update', scope, id, info),
|
||||||
|
delete: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:delete', scope, id),
|
||||||
|
addSong: (scope: 'local' | 'cloud', id: string, song: any, index = -1) => ipcRenderer.invoke('playlist:addSong', scope, id, toCloneableObject(song), index),
|
||||||
|
removeSong: (scope: 'local' | 'cloud', id: string, index: number) => ipcRenderer.invoke('playlist:removeSong', scope, id, index),
|
||||||
|
export: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:export', scope, id),
|
||||||
|
import: () => ipcRenderer.invoke('playlist:import'),
|
||||||
|
convertScope: (scope: 'local' | 'cloud', id: string, targetScope: 'local' | 'cloud') => ipcRenderer.invoke('playlist:convertScope', scope, id, targetScope),
|
||||||
|
copyToLocal: (scope: 'local' | 'cloud', id: string) => ipcRenderer.invoke('playlist:copyToLocal', scope, id),
|
||||||
|
},
|
||||||
|
|
||||||
|
image: {
|
||||||
|
selectAndUpload: () => ipcRenderer.invoke('image:selectAndUpload'),
|
||||||
|
},
|
||||||
|
|
||||||
|
privacy: {
|
||||||
|
getLibrary: () => ipcRenderer.invoke('privacy:getLibrary'),
|
||||||
|
setLibrary: (payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => ipcRenderer.invoke('privacy:setLibrary', toCloneableObject(payload)),
|
||||||
|
},
|
||||||
|
|
||||||
|
user: {
|
||||||
|
getProfile: (userId: string) => ipcRenderer.invoke('user:getProfile', userId),
|
||||||
|
getPlaylists: (userId: string) => ipcRenderer.invoke('user:getPlaylists', userId),
|
||||||
|
getFavSongs: (userId: string) => ipcRenderer.invoke('user:getFavSongs', userId),
|
||||||
|
updateProfile: (payload: any) => ipcRenderer.invoke('user:updateProfile', toCloneableObject(payload)),
|
||||||
|
},
|
||||||
|
|
||||||
// Cache Control
|
// Cache Control
|
||||||
getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'),
|
getCacheInfo: () => ipcRenderer.invoke('cache:getInfo'),
|
||||||
setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist),
|
setCachePersist: (persist: boolean) => ipcRenderer.invoke('cache:setPersist', persist),
|
||||||
@@ -44,6 +121,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
clearCache: () => ipcRenderer.invoke('cache:clear'),
|
clearCache: () => ipcRenderer.invoke('cache:clear'),
|
||||||
changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath),
|
changeCacheLocation: (newPath: string) => ipcRenderer.invoke('cache:changeLocation', newPath),
|
||||||
selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
|
selectDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
|
||||||
|
selectDirectories: () => ipcRenderer.invoke('dialog:openDirectories'),
|
||||||
|
|
||||||
|
localMusic: {
|
||||||
|
getLibrary: () => ipcRenderer.invoke('localMusic:getLibrary'),
|
||||||
|
scan: (roots: string[]) => ipcRenderer.invoke('localMusic:scan', toPlainStringArray(roots)),
|
||||||
|
setRoots: (roots: string[]) => ipcRenderer.invoke('localMusic:setRoots', toPlainStringArray(roots)),
|
||||||
|
remove: (id: string) => ipcRenderer.invoke('localMusic:remove', String(id)),
|
||||||
|
clearMissing: () => ipcRenderer.invoke('localMusic:clearMissing'),
|
||||||
|
},
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -1,30 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
<FullScreenPlayer />
|
<FullScreenPlayer />
|
||||||
|
<LoginDialog v-model:visible="showLoginDialog" />
|
||||||
<Settings v-if="showSettings" @close="showSettings = false" />
|
<Settings v-if="showSettings" @close="showSettings = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, provide, onMounted } from 'vue';
|
import { ref, provide, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import MainLayout from './layout/MainLayout.vue';
|
import MainLayout from './layout/MainLayout.vue';
|
||||||
import Settings from './components/Settings.vue';
|
import Settings from './components/Settings.vue';
|
||||||
import FullScreenPlayer from './components/FullScreenPlayer.vue';
|
import FullScreenPlayer from './components/FullScreenPlayer.vue';
|
||||||
|
import LoginDialog from './components/LoginDialog.vue';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import { usePlaylistsStore } from './stores/playlists';
|
||||||
|
import { usePlayerStore } from './stores/player';
|
||||||
|
|
||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
|
const showLoginDialog = ref(false);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const playlistsStore = usePlaylistsStore();
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
// Provide to child components
|
// Provide to child components
|
||||||
provide('openSettings', () => { showSettings.value = true; });
|
provide('openSettings', () => { showSettings.value = true; });
|
||||||
|
provide('openLoginDialog', () => { showLoginDialog.value = true; });
|
||||||
|
|
||||||
|
const isTypingTarget = (target: EventTarget | null) => {
|
||||||
|
const element = target as HTMLElement | null;
|
||||||
|
return Boolean(
|
||||||
|
element?.closest('input, textarea, select, [contenteditable="true"]')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||||
|
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey || isTypingTarget(event.target)) return;
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
playerStore.togglePlay();
|
||||||
|
} else if (key === 'a') {
|
||||||
|
event.preventDefault();
|
||||||
|
playerStore.prev();
|
||||||
|
} else if (key === 'd') {
|
||||||
|
event.preventDefault();
|
||||||
|
playerStore.next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Apply saved theme on app startup
|
// Apply saved theme on app startup
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', handleGlobalShortcut);
|
||||||
if (window.electronAPI?.settings) {
|
if (window.electronAPI?.settings) {
|
||||||
const settings = await window.electronAPI.settings.getAll();
|
const settings = await window.electronAPI.settings.getAll();
|
||||||
document.documentElement.setAttribute('data-theme', settings.theme);
|
document.documentElement.setAttribute('data-theme', settings.theme);
|
||||||
document.documentElement.style.setProperty('--color-accent', settings.accentColor);
|
const accentColor = settings.accentColor === '#b3c9df' ? '#8289d3' : settings.accentColor;
|
||||||
|
document.documentElement.style.setProperty('--color-accent', accentColor);
|
||||||
|
document.documentElement.style.setProperty('--color-accent-gradient', accentColor);
|
||||||
|
const atmosphere = accentColor === '#8289d3'
|
||||||
|
? 'linear-gradient(180deg, rgba(176, 186, 235, 0.36) 0%, rgba(177, 191, 233, 0.31) 18%, rgba(179, 201, 223, 0.25) 38%, rgba(193, 192, 211, 0.18) 58%, rgba(223, 172, 185, 0.11) 78%, transparent 100%)'
|
||||||
|
: 'linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, color-mix(in srgb, var(--color-accent) 7%, transparent) 44%, transparent 100%)';
|
||||||
|
document.documentElement.style.setProperty('--color-atmosphere-gradient', atmosphere);
|
||||||
}
|
}
|
||||||
|
await authStore.init();
|
||||||
|
await playlistsStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleGlobalShortcut);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import "styles/main.css";
|
@import "styles/main.css";
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
BIN
src/renderer/src/assets/long_width_bg.png
Normal file
BIN
src/renderer/src/assets/long_width_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 687 KiB |
@@ -16,7 +16,7 @@
|
|||||||
<ControlThumb @click="toggleFullScreen" />
|
<ControlThumb @click="toggleFullScreen" />
|
||||||
</div>
|
</div>
|
||||||
<Cover
|
<Cover
|
||||||
class="cover"
|
:class="['cover', { 'shared-cover': isPlayerFullScreen }]"
|
||||||
:cover-url="playerStore.currentSong?.picUrl"
|
:cover-url="playerStore.currentSong?.picUrl"
|
||||||
:music-paused="!isPlaying"
|
:music-paused="!isPlaying"
|
||||||
:cover-video-paused="!isPlaying"
|
:cover-video-paused="!isPlaying"
|
||||||
@@ -101,31 +101,7 @@
|
|||||||
<span class="playlist-title">播放队列</span>
|
<span class="playlist-title">播放队列</span>
|
||||||
<span class="playlist-count">{{ playerStore.playlist.length }} 首</span>
|
<span class="playlist-count">{{ playerStore.playlist.length }} 首</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="playlist-scroll">
|
<PlayerQueueList class="playlist-scroll" variant="fullscreen" />
|
||||||
<div
|
|
||||||
v-for="(song, index) in playerStore.playlist"
|
|
||||||
:key="song.id"
|
|
||||||
class="playlist-item"
|
|
||||||
:class="{ active: index === playerStore.playlist.findIndex(s => s.id === playerStore.currentSong?.id) }"
|
|
||||||
@click="playFromPlaylist(index)"
|
|
||||||
>
|
|
||||||
<div class="item-index">
|
|
||||||
<span v-if="index === playerStore.playlist.findIndex(s => s.id === playerStore.currentSong?.id)" class="playing-indicator">
|
|
||||||
<span class="bar"></span>
|
|
||||||
<span class="bar"></span>
|
|
||||||
<span class="bar"></span>
|
|
||||||
</span>
|
|
||||||
<span v-else>{{ index + 1 }}</span>
|
|
||||||
</div>
|
|
||||||
<img v-if="song.picUrl" :src="song.picUrl" class="item-cover" />
|
|
||||||
<div v-else class="item-cover item-cover-placeholder"></div>
|
|
||||||
<div class="item-info">
|
|
||||||
<div class="item-name">{{ song.name }}</div>
|
|
||||||
<div class="item-artist">{{ song.artist }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="item-duration">{{ song.duration }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<!-- Lyric Player -->
|
<!-- Lyric Player -->
|
||||||
@@ -181,6 +157,7 @@ import MusicInfo from './player/MusicInfo.vue';
|
|||||||
import MediaButton from './player/MediaButton.vue';
|
import MediaButton from './player/MediaButton.vue';
|
||||||
import VolumeControl from './player/VolumeControl.vue';
|
import VolumeControl from './player/VolumeControl.vue';
|
||||||
import ToggleIconButton from './player/ToggleIconButton.vue';
|
import ToggleIconButton from './player/ToggleIconButton.vue';
|
||||||
|
import PlayerQueueList from './player/PlayerQueueList.vue';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
import IconRewind from '@assets/icon_rewind.svg';
|
import IconRewind from '@assets/icon_rewind.svg';
|
||||||
@@ -357,13 +334,6 @@ const togglePlaylistPanel = () => {
|
|||||||
showPlaylistPanel.value = opening;
|
showPlaylistPanel.value = opening;
|
||||||
};
|
};
|
||||||
|
|
||||||
const playFromPlaylist = (index: number) => {
|
|
||||||
const song = playerStore.playlist[index];
|
|
||||||
if (song) {
|
|
||||||
playerStore.playSong(song);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// watch(()=>playerStore.currentTime,(t)=>{
|
// watch(()=>playerStore.currentTime,(t)=>{
|
||||||
// console.log(toRaw(t))
|
// console.log(toRaw(t))
|
||||||
// })
|
// })
|
||||||
@@ -373,18 +343,26 @@ const playFromPlaylist = (index: number) => {
|
|||||||
.fullscreen-player {
|
.fullscreen-player {
|
||||||
--height: calc(100vh);
|
--height: calc(100vh);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--height);
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--height);
|
height: var(--height);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
transition: top 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
opacity: 0;
|
||||||
|
transform: translateY(100%) scale(0.985);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
transition:
|
||||||
|
transform 0.46s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
opacity 0.28s ease;
|
||||||
background: black; /* Default background if image fails */
|
background: black; /* Default background if image fails */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreen-player.active {
|
.fullscreen-player.active {
|
||||||
top: 0;
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-container {
|
.background-container {
|
||||||
@@ -509,6 +487,10 @@ const playFromPlaylist = (index: number) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover.shared-cover {
|
||||||
|
view-transition-name: now-playing-cover;
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
grid-area: music-info / info-side;
|
grid-area: music-info / info-side;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
|||||||
307
src/renderer/src/components/LoginDialog.vue
Normal file
307
src/renderer/src/components/LoginDialog.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="login-fade">
|
||||||
|
<div v-if="visible" class="login-backdrop" @click.self="close">
|
||||||
|
<section class="login-panel" role="dialog" aria-modal="true" aria-label="登录">
|
||||||
|
<header class="login-header">
|
||||||
|
<div>
|
||||||
|
<h2>登录 QZ Music</h2>
|
||||||
|
<p>{{ statusText }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" title="关闭" @click="close">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="qr-stage" :class="{ muted: status === 'loading' || status === 'expired' || status === 'error' }">
|
||||||
|
<img v-if="session?.qr_data_url" :src="session.qr_data_url" alt="扫码登录二维码" />
|
||||||
|
<Icon v-else icon="lucide:qr-code" class="qr-placeholder" />
|
||||||
|
<div v-if="status === 'loading'" class="qr-overlay">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-actions">
|
||||||
|
<button
|
||||||
|
v-if="status === 'expired' || status === 'error' || status === 'cancelled'"
|
||||||
|
class="primary-btn"
|
||||||
|
@click="startQrLogin"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:refresh-cw" />
|
||||||
|
刷新二维码
|
||||||
|
</button>
|
||||||
|
<button class="secondary-btn" @click="openBrowserLogin">
|
||||||
|
<Icon icon="lucide:globe" />
|
||||||
|
浏览器登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { QrLoginSession } from '../types/electron'
|
||||||
|
|
||||||
|
const props = defineProps<{ visible: boolean }>()
|
||||||
|
const emit = defineEmits<{ (event: 'update:visible', value: boolean): void }>()
|
||||||
|
|
||||||
|
type QrStatus = 'idle' | 'loading' | 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'error'
|
||||||
|
|
||||||
|
const session = ref<QrLoginSession | null>(null)
|
||||||
|
const status = ref<QrStatus>('idle')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (status.value === 'loading') return '正在生成二维码'
|
||||||
|
if (status.value === 'scanned') return '已扫码,请在手机上确认'
|
||||||
|
if (status.value === 'success') return '登录成功'
|
||||||
|
if (status.value === 'expired') return '二维码已过期'
|
||||||
|
if (status.value === 'cancelled') return '二维码已取消'
|
||||||
|
if (status.value === 'error') return errorMessage.value || '二维码加载失败'
|
||||||
|
return '使用安卓端扫码,或选择浏览器登录'
|
||||||
|
})
|
||||||
|
|
||||||
|
const clearPollTimer = () => {
|
||||||
|
if (pollTimer) {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelActiveSession = async () => {
|
||||||
|
const current = session.value
|
||||||
|
if (!current || status.value === 'success') return
|
||||||
|
try {
|
||||||
|
await window.electronAPI.auth.qrCancel(current.session_id, current.poll_token)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Auth] cancel QR login failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollQrLogin = async () => {
|
||||||
|
const current = session.value
|
||||||
|
if (!current) return
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.auth.qrPoll(current.session_id, current.poll_token)
|
||||||
|
if (result.status === 'success') {
|
||||||
|
status.value = 'success'
|
||||||
|
clearPollTimer()
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
window.setTimeout(() => emit('update:visible', false), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === 'scanned') {
|
||||||
|
status.value = 'scanned'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === 'expired' || result.status === 'cancelled') {
|
||||||
|
status.value = result.status
|
||||||
|
clearPollTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === 'error') {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = result.message || '二维码登录失败'
|
||||||
|
clearPollTimer()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || '二维码登录失败'
|
||||||
|
clearPollTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
clearPollTimer()
|
||||||
|
pollTimer = window.setInterval(pollQrLogin, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startQrLogin = async () => {
|
||||||
|
clearPollTimer()
|
||||||
|
session.value = null
|
||||||
|
errorMessage.value = ''
|
||||||
|
status.value = 'loading'
|
||||||
|
try {
|
||||||
|
session.value = await window.electronAPI.auth.qrCreate()
|
||||||
|
status.value = 'pending'
|
||||||
|
startPolling()
|
||||||
|
} catch (err: any) {
|
||||||
|
status.value = 'error'
|
||||||
|
errorMessage.value = err?.message || '二维码加载失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBrowserLogin = async () => {
|
||||||
|
clearPollTimer()
|
||||||
|
await cancelActiveSession()
|
||||||
|
await window.electronAPI.auth.login(false)
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
clearPollTimer()
|
||||||
|
await cancelActiveSession()
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) startQrLogin()
|
||||||
|
else clearPollTimer()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPollTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 256px;
|
||||||
|
height: 256px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-stage.muted img {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-stage img {
|
||||||
|
width: 256px;
|
||||||
|
height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
width: 74px;
|
||||||
|
height: 74px;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.secondary-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-fade-enter-active,
|
||||||
|
.login-fade-leave-active {
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-fade-enter-from,
|
||||||
|
.login-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,6 +80,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 插件管理 -->
|
<!-- 插件管理 -->
|
||||||
|
<div v-else-if="activeCategory === 'privacy'" class="section">
|
||||||
|
<h2 class="section-title">隐私设置</h2>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">允许他人查看我的喜欢和歌单</div>
|
||||||
|
<div class="setting-desc">关闭后,公开歌单和喜欢的歌曲都只对自己可见</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch" :class="{ 'no-transition': !enableTransition }">
|
||||||
|
<input type="checkbox" v-model="allowPublicLibrary" :disabled="privacyLoading" @change="onPrivacyChange('library')" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">允许他人查看我的个人信息</div>
|
||||||
|
<div class="setting-desc">关闭后,地区、性别和生日只对自己可见</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch" :class="{ 'no-transition': !enableTransition }">
|
||||||
|
<input type="checkbox" v-model="allowPublicProfile" :disabled="privacyLoading" @change="onPrivacyChange('profile')" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeCategory === 'plugins'" class="section">
|
<div v-else-if="activeCategory === 'plugins'" class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -167,7 +195,7 @@
|
|||||||
:key="color.value"
|
:key="color.value"
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
:class="{ active: appearance.accentColor === color.value }"
|
:class="{ active: appearance.accentColor === color.value }"
|
||||||
:style="{ '--swatch-color': color.value }"
|
:style="{ '--swatch-color': color.value, '--swatch-bg': color.gradient || color.value }"
|
||||||
:title="color.name"
|
:title="color.name"
|
||||||
@click="setAccentColor(color.value)"
|
@click="setAccentColor(color.value)"
|
||||||
>
|
>
|
||||||
@@ -202,6 +230,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">歌单加载方式</div>
|
||||||
|
<div class="setting-desc">选择歌单和专辑页面的歌曲加载方式</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="segmented-control">
|
||||||
|
<button
|
||||||
|
class="segment-btn"
|
||||||
|
:class="{ active: playlistPagingMode === 'infinite' }"
|
||||||
|
@click="setPlaylistPagingMode('infinite')"
|
||||||
|
>
|
||||||
|
下滑加载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="segment-btn"
|
||||||
|
:class="{ active: playlistPagingMode === 'pagination' }"
|
||||||
|
@click="setPlaylistPagingMode('pagination')"
|
||||||
|
>
|
||||||
|
页码分页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">点击歌曲后打开播放页</div>
|
||||||
|
<div class="setting-desc">从搜索、歌单、推荐里点击歌曲播放时,自动进入全屏播放页</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" v-model="openPlayerOnSongClick" @change="onOpenPlayerPreferenceChange" />
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
||||||
<p>音质、淡入淡出等设置即将推出</p>
|
<p>音质、淡入淡出等设置即将推出</p>
|
||||||
@@ -209,7 +275,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快捷键 -->
|
<!-- 快捷键 -->
|
||||||
<div v-else-if="activeCategory === 'shortcuts'" class="section">
|
<div v-else-if="activeCategory === 'shortcuts'" class="section shortcuts-section">
|
||||||
|
<h2 class="shortcut-title">快捷键</h2>
|
||||||
|
<div class="shortcut-list">
|
||||||
|
<div v-for="item in shortcutRows" :key="item.key" class="shortcut-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">{{ item.name }}</div>
|
||||||
|
<div class="setting-desc">{{ item.desc }}</div>
|
||||||
|
</div>
|
||||||
|
<kbd>{{ item.key }}</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h2 class="section-title">快捷键</h2>
|
<h2 class="section-title">快捷键</h2>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<Icon icon="lucide:keyboard" class="placeholder-icon" />
|
<Icon icon="lucide:keyboard" class="placeholder-icon" />
|
||||||
@@ -260,6 +336,7 @@ defineEmits(['close']);
|
|||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
|
{ id: 'storage', name: '存储', icon: 'lucide:hard-drive' },
|
||||||
|
{ id: 'privacy', name: '隐私', icon: 'lucide:shield' },
|
||||||
{ id: 'plugins', name: '插件', icon: 'lucide:blocks' },
|
{ id: 'plugins', name: '插件', icon: 'lucide:blocks' },
|
||||||
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
|
{ id: 'appearance', name: '外观', icon: 'lucide:palette' },
|
||||||
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
|
{ id: 'playback', name: '播放', icon: 'lucide:headphones' },
|
||||||
@@ -268,6 +345,11 @@ const categories = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const accentColors = [
|
const accentColors = [
|
||||||
|
{
|
||||||
|
name: '默认蓝紫',
|
||||||
|
value: '#8289d3',
|
||||||
|
gradient: 'linear-gradient(135deg, #b0baeb 0%, #b1bfe9 24%, #b3c9df 48%, #c1c0d3 72%, #dfacb9 100%)',
|
||||||
|
},
|
||||||
{ name: '红色', value: '#ec4141' },
|
{ name: '红色', value: '#ec4141' },
|
||||||
{ name: '橙色', value: '#f97316' },
|
{ name: '橙色', value: '#f97316' },
|
||||||
{ name: '金色', value: '#eab308' },
|
{ name: '金色', value: '#eab308' },
|
||||||
@@ -282,6 +364,16 @@ const activeCategory = ref('storage');
|
|||||||
const isLoaded = ref(false);
|
const isLoaded = ref(false);
|
||||||
const enableTransition = ref(false);
|
const enableTransition = ref(false);
|
||||||
const plugins = ref<any[]>([]);
|
const plugins = ref<any[]>([]);
|
||||||
|
const playlistPagingMode = ref<'infinite' | 'pagination'>('infinite');
|
||||||
|
const openPlayerOnSongClick = ref(false);
|
||||||
|
const allowPublicLibrary = ref(false);
|
||||||
|
const allowPublicProfile = ref(false);
|
||||||
|
const privacyLoading = ref(false);
|
||||||
|
const shortcutRows = [
|
||||||
|
{ key: 'Space', name: '播放 / 暂停', desc: '在非输入状态下切换当前播放状态' },
|
||||||
|
{ key: 'A', name: '上一首', desc: '切换到播放队列里的上一首歌曲' },
|
||||||
|
{ key: 'D', name: '下一首', desc: '切换到播放队列里的下一首歌曲' },
|
||||||
|
];
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
persistCache: true,
|
persistCache: true,
|
||||||
@@ -289,7 +381,7 @@ const settings = reactive({
|
|||||||
|
|
||||||
const appearance = reactive({
|
const appearance = reactive({
|
||||||
theme: 'dark' as 'dark' | 'light',
|
theme: 'dark' as 'dark' | 'light',
|
||||||
accentColor: '#ec4141',
|
accentColor: '#8289d3',
|
||||||
});
|
});
|
||||||
|
|
||||||
const cacheInfo = reactive({
|
const cacheInfo = reactive({
|
||||||
@@ -374,18 +466,58 @@ const loadAppearance = async () => {
|
|||||||
if (window.electronAPI?.settings) {
|
if (window.electronAPI?.settings) {
|
||||||
const allSettings = await window.electronAPI.settings.getAll();
|
const allSettings = await window.electronAPI.settings.getAll();
|
||||||
appearance.theme = allSettings.theme;
|
appearance.theme = allSettings.theme;
|
||||||
appearance.accentColor = allSettings.accentColor;
|
appearance.accentColor = allSettings.accentColor === '#b3c9df' ? '#8289d3' : allSettings.accentColor;
|
||||||
|
playlistPagingMode.value = allSettings.playlistPagingMode || 'infinite';
|
||||||
|
openPlayerOnSongClick.value = Boolean(allSettings.openPlayerOnSongClick);
|
||||||
applyTheme(appearance.theme);
|
applyTheme(appearance.theme);
|
||||||
applyAccentColor(appearance.accentColor);
|
applyAccentColor(appearance.accentColor);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPrivacy = async () => {
|
||||||
|
if (!window.electronAPI?.privacy?.getLibrary) return;
|
||||||
|
try {
|
||||||
|
privacyLoading.value = true;
|
||||||
|
const data = await window.electronAPI.privacy.getLibrary();
|
||||||
|
allowPublicLibrary.value = Boolean(data?.allow_public_library);
|
||||||
|
allowPublicProfile.value = Boolean(data?.allow_public_profile);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load privacy settings', e);
|
||||||
|
} finally {
|
||||||
|
privacyLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPrivacyChange = async (target: 'library' | 'profile') => {
|
||||||
|
if (!window.electronAPI?.privacy?.setLibrary) return;
|
||||||
|
try {
|
||||||
|
privacyLoading.value = true;
|
||||||
|
const payload = target === 'library'
|
||||||
|
? { allow_public_library: allowPublicLibrary.value }
|
||||||
|
: { allow_public_profile: allowPublicProfile.value };
|
||||||
|
const data = await window.electronAPI.privacy.setLibrary(payload);
|
||||||
|
allowPublicLibrary.value = Boolean(data?.allow_public_library);
|
||||||
|
allowPublicProfile.value = Boolean(data?.allow_public_profile);
|
||||||
|
ElMessage.success('隐私设置已更新');
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('隐私设置更新失败');
|
||||||
|
await loadPrivacy();
|
||||||
|
} finally {
|
||||||
|
privacyLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyTheme = (theme: 'dark' | 'light') => {
|
const applyTheme = (theme: 'dark' | 'light') => {
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAccentColor = (color: string) => {
|
const applyAccentColor = (color: string) => {
|
||||||
document.documentElement.style.setProperty('--color-accent', color);
|
document.documentElement.style.setProperty('--color-accent', color);
|
||||||
|
document.documentElement.style.setProperty('--color-accent-gradient', color);
|
||||||
|
const atmosphere = color === '#8289d3'
|
||||||
|
? 'linear-gradient(180deg, rgba(176, 186, 235, 0.36) 0%, rgba(177, 191, 233, 0.31) 18%, rgba(179, 201, 223, 0.25) 38%, rgba(193, 192, 211, 0.18) 58%, rgba(223, 172, 185, 0.11) 78%, transparent 100%)'
|
||||||
|
: 'linear-gradient(180deg, color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, color-mix(in srgb, var(--color-accent) 7%, transparent) 44%, transparent 100%)';
|
||||||
|
document.documentElement.style.setProperty('--color-atmosphere-gradient', atmosphere);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTheme = async (theme: 'dark' | 'light') => {
|
const setTheme = async (theme: 'dark' | 'light') => {
|
||||||
@@ -404,6 +536,23 @@ const setAccentColor = async (color: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPlaylistPagingMode = async (mode: 'infinite' | 'pagination') => {
|
||||||
|
playlistPagingMode.value = mode;
|
||||||
|
if (window.electronAPI?.settings) {
|
||||||
|
await window.electronAPI.settings.set({ playlistPagingMode: mode });
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('qz-playlist-page-mode-changed', { detail: mode }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenPlayerPreferenceChange = async () => {
|
||||||
|
if (window.electronAPI?.settings) {
|
||||||
|
await window.electronAPI.settings.set({ openPlayerOnSongClick: openPlayerOnSongClick.value });
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('qz-open-player-on-song-click-changed', {
|
||||||
|
detail: openPlayerOnSongClick.value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const onCacheToggle = async () => {
|
const onCacheToggle = async () => {
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
await window.electronAPI.setCachePersist(settings.persistCache);
|
await window.electronAPI.setCachePersist(settings.persistCache);
|
||||||
@@ -450,7 +599,7 @@ const changeCacheLocation = async () => {
|
|||||||
|
|
||||||
// Load settings BEFORE mount to avoid visual flicker
|
// Load settings BEFORE mount to avoid visual flicker
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await Promise.all([loadCacheInfo(), loadAppearance()]);
|
await Promise.all([loadCacheInfo(), loadAppearance(), loadPrivacy()]);
|
||||||
isLoaded.value = true;
|
isLoaded.value = true;
|
||||||
// Enable transition after initial render
|
// Enable transition after initial render
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -680,7 +829,7 @@ onBeforeMount(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .toggle-slider {
|
input:checked + .toggle-slider {
|
||||||
background-color: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:checked + .toggle-slider:before {
|
input:checked + .toggle-slider:before {
|
||||||
@@ -835,21 +984,24 @@ input:checked + .toggle-slider:before {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
border: 3px solid transparent;
|
border: 3px solid transparent;
|
||||||
background-color: var(--swatch-color);
|
background: var(--swatch-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
transition: border-color 0.18s ease, outline-color 0.18s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
outline: 1px solid color-mix(in srgb, var(--swatch-color) 22%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-swatch:hover {
|
.color-swatch:hover {
|
||||||
transform: scale(1.1);
|
border-color: color-mix(in srgb, var(--swatch-color) 34%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-swatch.active {
|
.color-swatch.active {
|
||||||
box-shadow: 0 0 16px var(--swatch-color);
|
border-color: var(--color-bg-primary);
|
||||||
|
outline-color: var(--swatch-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-icon {
|
.check-icon {
|
||||||
@@ -895,6 +1047,77 @@ input:checked + .toggle-slider:before {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-btn.active {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-section .placeholder-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-section > .section-title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-row {
|
||||||
|
min-height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
min-width: 58px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: 700 13px var(--font-family-base);
|
||||||
|
}
|
||||||
|
|
||||||
/* Plugin Card Styles */
|
/* Plugin Card Styles */
|
||||||
.plugin-list {
|
.plugin-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,94 +1,196 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<!-- 顶部区域 -->
|
<div class="brand">
|
||||||
<div class="sidebar-header">
|
<div class="brand-mark">
|
||||||
<div class="logo-area">
|
<Icon icon="lucide:music-2" />
|
||||||
<div class="logo-icon">🎶</div>
|
</div>
|
||||||
<span class="app-name">QZ Music</span>
|
<div class="brand-copy">
|
||||||
|
<div class="brand-name">QZ Music</div>
|
||||||
|
<div class="brand-subtitle">Private listening room</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主导航 -->
|
<nav class="nav-section">
|
||||||
<div class="nav-section">
|
|
||||||
<router-link to="/" class="nav-item" active-class="active">
|
<router-link to="/" class="nav-item" active-class="active">
|
||||||
<Icon icon="lucide:home" class="nav-icon" />
|
<Icon icon="lucide:home" />
|
||||||
<span class="nav-text">推荐</span>
|
<span>主页</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link to="/local" class="nav-item" active-class="active">
|
<router-link to="/local" class="nav-item" active-class="active">
|
||||||
<Icon icon="lucide:hard-drive" class="nav-icon" />
|
<Icon icon="lucide:hard-drive" />
|
||||||
<span class="nav-text">本地音乐</span>
|
<span>本地音乐</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 我的音乐 -->
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="nav-section">
|
|
||||||
<div class="section-title">我的音乐</div>
|
|
||||||
<router-link to="/liked" class="nav-item" active-class="active">
|
<router-link to="/liked" class="nav-item" active-class="active">
|
||||||
<Icon icon="lucide:heart" class="nav-icon" />
|
<Icon icon="lucide:heart" />
|
||||||
<span class="nav-text">我喜欢的</span>
|
<span>我喜欢的</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link to="/recent" class="nav-item" active-class="active">
|
<router-link to="/recent" class="nav-item" active-class="active">
|
||||||
<Icon icon="lucide:clock" class="nav-icon" />
|
<Icon icon="lucide:clock-3" />
|
||||||
<span class="nav-text">最近播放</span>
|
<span>最近播放</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="nav-item">
|
<router-link to="/together" class="nav-item" active-class="active">
|
||||||
<Icon icon="lucide:download" class="nav-icon" />
|
<Icon icon="lucide:users-round" />
|
||||||
<span class="nav-text">下载管理</span>
|
<span>一起听</span>
|
||||||
</div>
|
</router-link>
|
||||||
</div>
|
<router-link to="/listen-stats" class="nav-item" active-class="active">
|
||||||
|
<Icon icon="lucide:activity" />
|
||||||
|
<span>听歌足迹</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/listen-rank" class="nav-item" active-class="active">
|
||||||
|
<Icon icon="lucide:trophy" />
|
||||||
|
<span>排行</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/playlist-square" class="nav-item" active-class="active">
|
||||||
|
<Icon icon="lucide:layout-grid" />
|
||||||
|
<span>歌单广场</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- 我的歌单 -->
|
<div class="section-divider"></div>
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="nav-section">
|
<section class="playlist-section">
|
||||||
<div class="section-header" @click="togglePlaylists">
|
<div class="section-header">
|
||||||
<span class="section-title">我的歌单</span>
|
<button class="section-title" @click="isPlaylistsOpen = !isPlaylistsOpen">
|
||||||
<Icon
|
<Icon icon="lucide:chevron-down" :class="{ collapsed: !isPlaylistsOpen }" />
|
||||||
icon="lucide:chevron-down"
|
<span>我的歌单</span>
|
||||||
class="collapse-icon"
|
</button>
|
||||||
:class="{ 'collapsed': !isPlaylistsOpen }"
|
<div class="playlist-action-wrap">
|
||||||
/>
|
<button class="flat-icon" title="歌单操作" @click.stop="showPlaylistActionMenu = !showPlaylistActionMenu">
|
||||||
</div>
|
<Icon icon="lucide:plus" />
|
||||||
|
</button>
|
||||||
<div class="playlists-list" v-show="isPlaylistsOpen">
|
<div v-if="showPlaylistActionMenu" class="playlist-action-menu" @click.stop>
|
||||||
<div class="nav-item playlist-item">
|
<button @click="openCreateDialog">
|
||||||
<div class="playlist-cover">
|
<Icon icon="lucide:list-plus" />
|
||||||
<Icon icon="lucide:music" />
|
<span>新建歌单</span>
|
||||||
|
</button>
|
||||||
|
<button @click="importPlaylistFile">
|
||||||
|
<Icon icon="lucide:upload" />
|
||||||
|
<span>导入歌单</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-text">驾驶模式</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item playlist-item">
|
|
||||||
<div class="playlist-cover">
|
|
||||||
<Icon icon="lucide:music" />
|
|
||||||
</div>
|
|
||||||
<span class="nav-text">放松时光</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item playlist-item">
|
|
||||||
<div class="playlist-cover">
|
|
||||||
<Icon icon="lucide:music" />
|
|
||||||
</div>
|
|
||||||
<span class="nav-text">工作专注</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item create-playlist">
|
|
||||||
<Icon icon="lucide:plus" class="nav-icon" />
|
|
||||||
<span class="nav-text">新建歌单</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div v-show="isPlaylistsOpen" class="playlist-list">
|
||||||
|
<router-link
|
||||||
|
v-for="playlist in playlistStore.all"
|
||||||
|
:key="`${playlist.scope}:${playlist.id}`"
|
||||||
|
:to="{ name: 'PlaylistDetail', params: { scope: playlist.scope, id: playlist.id } }"
|
||||||
|
class="playlist-link"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
<div class="mini-cover">
|
||||||
|
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
|
||||||
|
<Icon v-else :icon="playlist.scope === 'cloud' ? 'lucide:cloud' : 'lucide:list-music'" />
|
||||||
|
</div>
|
||||||
|
<div class="playlist-copy">
|
||||||
|
<span class="playlist-name">{{ playlist.info.name }}</span>
|
||||||
|
<span class="playlist-meta">{{ playlist.scope === 'cloud' ? '云端' : '本地' }} · {{ playlist.total }} 首</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<button v-if="playlistStore.all.length === 0" class="empty-create" @click="openCreateDialog">
|
||||||
|
<Icon icon="lucide:plus" />
|
||||||
|
<span>创建第一个歌单</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showCreateDialog" class="dialog-backdrop" @click.self="showCreateDialog = false">
|
||||||
|
<div class="create-dialog">
|
||||||
|
<div class="dialog-title">新建歌单</div>
|
||||||
|
<input v-model="draftName" class="text-input" placeholder="歌单名称" />
|
||||||
|
<textarea v-model="draftDesc" class="text-area" placeholder="简介,可选"></textarea>
|
||||||
|
<div class="scope-tabs">
|
||||||
|
<button :class="{ active: draftScope === 'local' }" @click="draftScope = 'local'">
|
||||||
|
<Icon icon="lucide:hard-drive" />
|
||||||
|
本地
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: draftScope === 'cloud' }" :disabled="!authStore.isLoggedIn" @click="draftScope = 'cloud'">
|
||||||
|
<Icon icon="lucide:cloud" />
|
||||||
|
云端
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-if="draftScope === 'cloud'" class="public-option">
|
||||||
|
<input type="checkbox" v-model="draftIsPublic" />
|
||||||
|
<span>公开歌单</span>
|
||||||
|
</label>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="ghost-btn" @click="showCreateDialog = false">取消</button>
|
||||||
|
<button class="primary-btn" :disabled="!draftName.trim()" @click="createPlaylist">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Icon } from '@iconify/vue';
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { usePlaylistsStore, type PlaylistScope } from '../stores/playlists'
|
||||||
|
|
||||||
const isPlaylistsOpen = ref(true);
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const playlistStore = usePlaylistsStore()
|
||||||
|
|
||||||
const togglePlaylists = () => {
|
const isPlaylistsOpen = ref(true)
|
||||||
isPlaylistsOpen.value = !isPlaylistsOpen.value;
|
const showCreateDialog = ref(false)
|
||||||
};
|
const showPlaylistActionMenu = ref(false)
|
||||||
|
const draftName = ref('')
|
||||||
|
const draftDesc = ref('')
|
||||||
|
const draftScope = ref<PlaylistScope>('local')
|
||||||
|
const draftIsPublic = ref(false)
|
||||||
|
|
||||||
|
const closePlaylistActionMenu = (event?: MouseEvent) => {
|
||||||
|
const target = event?.target as HTMLElement | null
|
||||||
|
if (target?.closest('.playlist-action-wrap')) return
|
||||||
|
showPlaylistActionMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
showPlaylistActionMenu.value = false
|
||||||
|
draftName.value = ''
|
||||||
|
draftDesc.value = ''
|
||||||
|
draftScope.value = 'local'
|
||||||
|
draftIsPublic.value = false
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const importPlaylistFile = async () => {
|
||||||
|
showPlaylistActionMenu.value = false
|
||||||
|
const result = await playlistStore.importPlaylist()
|
||||||
|
if (result?.success && result.playlist) {
|
||||||
|
router.push({ name: 'PlaylistDetail', params: { scope: result.playlist.scope, id: result.playlist.id } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPlaylist = async () => {
|
||||||
|
if (draftScope.value === 'cloud' && !authStore.isLoggedIn) {
|
||||||
|
ElMessage.warning('请先登录后再创建云端歌单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const playlist = await playlistStore.create(draftScope.value, {
|
||||||
|
name: draftName.value,
|
||||||
|
desc: draftDesc.value,
|
||||||
|
is_public: draftScope.value === 'cloud' ? draftIsPublic.value : false,
|
||||||
|
})
|
||||||
|
showCreateDialog.value = false
|
||||||
|
router.push({ name: 'PlaylistDetail', params: { scope: playlist.scope, id: playlist.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('click', closePlaylistActionMenu)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('click', closePlaylistActionMenu)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -96,207 +198,386 @@ const togglePlaylists = () => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: var(--color-bg-secondary);
|
background:
|
||||||
border-right: 1px solid var(--color-border);
|
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-secondary) 94%, transparent), color-mix(in srgb, var(--color-bg-primary) 86%, transparent));
|
||||||
|
border-right: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px 12px;
|
padding: 24px 18px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式 - 默认隐藏,悬停时显示 */
|
.sidebar::before {
|
||||||
.sidebar::-webkit-scrollbar {
|
content: '';
|
||||||
width: 6px;
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 220px;
|
||||||
|
background: var(--color-atmosphere-gradient);
|
||||||
|
opacity: 0.95;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-track {
|
.sidebar > * {
|
||||||
background: transparent;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar::-webkit-scrollbar-thumb {
|
.brand {
|
||||||
background: transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar:hover::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar:hover::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部Logo区域 */
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 8px 8px 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-area {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
padding: 8px;
|
padding: 2px 10px 26px;
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-area:hover {
|
.brand-mark {
|
||||||
background-color: var(--color-bg-tertiary);
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, color-mix(in srgb, var(--color-accent) 16%, transparent), transparent),
|
||||||
|
var(--color-bg-primary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.brand-mark svg {
|
||||||
width: 40px;
|
width: 23px;
|
||||||
height: 40px;
|
height: 23px;
|
||||||
background: linear-gradient(135deg, #ec4141, #ff6b6b);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-name {
|
.brand-copy {
|
||||||
font-size: var(--font-size-lg);
|
min-width: 0;
|
||||||
font-weight: 600;
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 760;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航区域 */
|
.brand-subtitle {
|
||||||
.nav-section {
|
margin-top: 4px;
|
||||||
margin-bottom: 8px;
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section,
|
||||||
|
.playlist-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item,
|
||||||
|
.playlist-link,
|
||||||
|
.empty-create,
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
font-size: 13px;
|
||||||
align-items: center;
|
font-weight: 560;
|
||||||
padding: 12px 16px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition-base);
|
|
||||||
text-decoration: none;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item svg,
|
||||||
background-color: var(--color-bg-tertiary);
|
.section-title svg,
|
||||||
|
.flat-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-icon:hover,
|
||||||
|
.nav-item:hover,
|
||||||
|
.playlist-link:hover,
|
||||||
|
.empty-create:hover,
|
||||||
|
.section-title:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active,
|
||||||
background-color: var(--color-accent-soft);
|
.playlist-link.active {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
.nav-icon {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: transform var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.nav-text {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分割线 */
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(to right, transparent, var(--color-border), transparent);
|
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 18%, transparent), transparent);
|
||||||
margin: 16px 8px;
|
margin: 18px 8px;
|
||||||
opacity: 0.6;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 区域标题 */
|
|
||||||
.section-title {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 16px;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 760;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
transition: all var(--transition-base);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header:hover {
|
.section-title svg {
|
||||||
color: var(--color-text-primary);
|
transition: transform 160ms ease;
|
||||||
background-color: var(--color-bg-tertiary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-icon {
|
.section-title svg.collapsed {
|
||||||
transition: transform var(--transition-base);
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapse-icon.collapsed {
|
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 歌单项 */
|
.flat-icon {
|
||||||
.playlist-item {
|
width: 30px;
|
||||||
padding: 10px 16px;
|
height: 30px;
|
||||||
}
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
.playlist-cover {
|
place-items: center;
|
||||||
width: 36px;
|
color: var(--color-text-secondary);
|
||||||
height: 36px;
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 12px;
|
|
||||||
color: white;
|
|
||||||
font-size: 14px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-item:hover .playlist-cover {
|
.playlist-action-wrap {
|
||||||
transform: scale(1.05);
|
position: relative;
|
||||||
box-shadow: var(--shadow-md);
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 新建歌单 */
|
.playlist-action-menu {
|
||||||
.create-playlist {
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: 150px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 94%, transparent);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-action-menu button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-action-menu button:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-action-menu svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-link {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-cover {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-top: 8px;
|
flex-shrink: 0;
|
||||||
border: 1px dashed var(--color-border-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-playlist:hover {
|
.mini-cover img {
|
||||||
border-color: var(--color-accent);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-name,
|
||||||
|
.playlist-meta {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-create {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn,
|
||||||
|
.primary-btn,
|
||||||
|
.ghost-btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn,
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-dialog {
|
||||||
|
width: min(380px, calc(100vw - 32px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 750;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input,
|
||||||
|
.text-area {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
outline: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-area {
|
||||||
|
min-height: 88px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus,
|
||||||
|
.text-area:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent) 34%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 4px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tabs button {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-tabs button.active {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
background-color: var(--color-accent-soft);
|
}
|
||||||
|
|
||||||
|
.scope-tabs button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-option {
|
||||||
|
min-height: 32px;
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-option input {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
425
src/renderer/src/components/SongTile.vue
Normal file
425
src/renderer/src/components/SongTile.vue
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<template>
|
||||||
|
<div class="song-tile" :class="{ 'with-action': removable || reserveAction }" @click="$emit('play')" @contextmenu.prevent="openMenu">
|
||||||
|
<div class="song-index">{{ displayIndex }}</div>
|
||||||
|
<div class="song-cover">
|
||||||
|
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" alt="" />
|
||||||
|
<Icon v-else icon="lucide:music" />
|
||||||
|
</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<h4 class="song-title" v-html="renderText(song.name)"></h4>
|
||||||
|
<p class="song-artist" v-html="renderText(song.artist)"></p>
|
||||||
|
</div>
|
||||||
|
<div class="song-album" v-html="renderText(song.albumName || '-')"></div>
|
||||||
|
<div class="song-duration">{{ song.duration || '--:--' }}</div>
|
||||||
|
<div v-if="removable || reserveAction" class="song-action">
|
||||||
|
<button v-if="removable" class="remove-btn" title="移出歌单" @click.stop="$emit('remove')">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="menu-fade">
|
||||||
|
<div
|
||||||
|
v-if="menuOpen"
|
||||||
|
ref="menuRef"
|
||||||
|
class="song-menu"
|
||||||
|
:style="{ left: `${menuPosition.x}px`, top: `${menuPosition.y}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button class="menu-row" @click="playNext">
|
||||||
|
<Icon icon="lucide:list-start" />
|
||||||
|
<span>下一首播放</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-row" @click="appendToQueue">
|
||||||
|
<Icon icon="lucide:list-plus" />
|
||||||
|
<span>添加到播放列表末</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-divider"></div>
|
||||||
|
<div class="menu-label">添加到歌单</div>
|
||||||
|
<button
|
||||||
|
v-for="playlist in writablePlaylists"
|
||||||
|
:key="`${playlist.scope}:${playlist.id}`"
|
||||||
|
class="menu-row"
|
||||||
|
:disabled="addingPlaylistKey === `${playlist.scope}:${playlist.id}`"
|
||||||
|
@click="addToPlaylist(playlist.scope, playlist.id)"
|
||||||
|
>
|
||||||
|
<div class="playlist-menu-cover">
|
||||||
|
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
|
||||||
|
<Icon v-else :icon="playlist.scope === 'cloud' ? 'lucide:cloud' : 'lucide:hard-drive'" />
|
||||||
|
<span class="playlist-source-badge" :class="playlist.scope === 'cloud' ? 'cloud' : 'local'">
|
||||||
|
{{ playlist.scope === 'cloud' ? '云' : '本' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="addingPlaylistKey === `${playlist.scope}:${playlist.id}`" class="cover-loading">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>{{ playlist.info.name }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="writablePlaylists.length === 0" class="menu-empty">暂无歌单</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, toRaw } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { Song } from '../types/song'
|
||||||
|
import { usePlayerStore } from '../stores/player'
|
||||||
|
import { usePlaylistsStore, type AppPlaylist, type ManagedPlaylistScope } from '../stores/playlists'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
song: Song
|
||||||
|
displayIndex: number
|
||||||
|
highlight?: (text: string) => string
|
||||||
|
removable?: boolean
|
||||||
|
reserveAction?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
play: []
|
||||||
|
remove: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const playlistStore = usePlaylistsStore()
|
||||||
|
const writablePlaylists = computed(() => (
|
||||||
|
playlistStore.all.filter((playlist) => playlist.scope !== 'plugin') as Array<AppPlaylist & { scope: ManagedPlaylistScope }>
|
||||||
|
))
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
|
const menuPosition = ref({ x: 0, y: 0 })
|
||||||
|
const addingPlaylistKey = ref('')
|
||||||
|
|
||||||
|
const renderText = (text: string) => props.highlight ? props.highlight(text) : text
|
||||||
|
|
||||||
|
const toPlainSong = (song: Song): Song => {
|
||||||
|
const raw = toRaw(song) as Song & Record<string, any>
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ''),
|
||||||
|
hash: raw.hash ?? null,
|
||||||
|
picUrl: String(raw.picUrl ?? ''),
|
||||||
|
url: String(raw.url ?? ''),
|
||||||
|
name: String(raw.name ?? ''),
|
||||||
|
artist: String(raw.artist ?? ''),
|
||||||
|
duration: String(raw.duration ?? ''),
|
||||||
|
source: String(raw.source ?? ''),
|
||||||
|
lyric: typeof raw.lyric === 'string' ? raw.lyric : undefined,
|
||||||
|
quality: raw.quality,
|
||||||
|
albumId: raw.albumId ?? null,
|
||||||
|
albumName: raw.albumName ?? null,
|
||||||
|
artistIds: Array.isArray(raw.artistIds) ? raw.artistIds.map(String) : null,
|
||||||
|
type: raw.type,
|
||||||
|
types: raw.types && typeof raw.types === 'object' ? { ...raw.types } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
menuOpen.value = false
|
||||||
|
window.removeEventListener('click', closeMenu)
|
||||||
|
window.removeEventListener('scroll', closeMenu, true)
|
||||||
|
window.removeEventListener('resize', closeMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampMenuPosition = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const menu = menuRef.value
|
||||||
|
if (!menu) return
|
||||||
|
const rect = menu.getBoundingClientRect()
|
||||||
|
const margin = 12
|
||||||
|
menuPosition.value = {
|
||||||
|
x: Math.min(menuPosition.value.x, window.innerWidth - rect.width - margin),
|
||||||
|
y: Math.min(menuPosition.value.y, window.innerHeight - rect.height - margin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = async (event: MouseEvent) => {
|
||||||
|
menuPosition.value = { x: event.clientX, y: event.clientY }
|
||||||
|
menuOpen.value = true
|
||||||
|
window.addEventListener('click', closeMenu)
|
||||||
|
window.addEventListener('scroll', closeMenu, true)
|
||||||
|
window.addEventListener('resize', closeMenu)
|
||||||
|
playlistStore.refresh().catch((error) => console.warn('[SongTile] Failed to refresh playlists:', error))
|
||||||
|
await clampMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
const playNext = async () => {
|
||||||
|
await playerStore.playNextInQueue(toPlainSong(props.song))
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendToQueue = async () => {
|
||||||
|
await playerStore.appendToQueue(toPlainSong(props.song))
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToPlaylist = async (scope: ManagedPlaylistScope, id: string) => {
|
||||||
|
const key = `${scope}:${id}`
|
||||||
|
if (addingPlaylistKey.value) return
|
||||||
|
addingPlaylistKey.value = key
|
||||||
|
try {
|
||||||
|
await playlistStore.addSong(scope, id, toPlainSong(props.song))
|
||||||
|
closeMenu()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SongTile] add to playlist failed:', error)
|
||||||
|
ElMessage.error(error?.message || '添加到歌单失败')
|
||||||
|
} finally {
|
||||||
|
addingPlaylistKey.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(closeMenu)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.song-tile {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tile.with-action {
|
||||||
|
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tile:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-index {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title,
|
||||||
|
.song-artist,
|
||||||
|
.song-album {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-album {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-duration {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease, background-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tile:hover .remove-btn,
|
||||||
|
.remove-btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
background: rgba(255, 95, 95, 0.14);
|
||||||
|
color: #ff7070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-menu {
|
||||||
|
position: fixed;
|
||||||
|
width: 232px;
|
||||||
|
max-height: min(360px, calc(100vh - 24px));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 96%, white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-row span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-menu-cover {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-menu-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-menu-cover > svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-source-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -1px;
|
||||||
|
min-width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 6px 0 8px 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-bg-primary) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-source-badge.cloud {
|
||||||
|
background: #4d8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-source-badge.local {
|
||||||
|
background: #21a56b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 6px 6px;
|
||||||
|
background: color-mix(in srgb, var(--color-border) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label,
|
||||||
|
.menu-empty {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-fade-enter-active,
|
||||||
|
.menu-fade-leave-active {
|
||||||
|
transition: opacity 150ms ease, transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-fade-enter-from,
|
||||||
|
.menu-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="left-controls">
|
<div class="left-controls">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<button class="nav-btn ripple-btn" @click="goBack" title="返回">
|
<button class="nav-btn" @click="goBack" title="返回">
|
||||||
<Icon icon="lucide:chevron-left" class="nav-icon" />
|
<Icon icon="lucide:chevron-left" />
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn ripple-btn" @click="goForward" title="前进">
|
<button class="nav-btn" @click="goForward" title="前进">
|
||||||
<Icon icon="lucide:chevron-right" class="nav-icon" />
|
<Icon icon="lucide:chevron-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,36 +14,63 @@
|
|||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<Icon icon="lucide:search" class="search-icon" />
|
<Icon icon="lucide:search" class="search-icon" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索音乐、歌手、专辑..."
|
placeholder="搜索音乐、歌手、专辑..."
|
||||||
class="search-input"
|
class="search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
@keydown.enter="handleSearch"
|
@keydown.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="right-controls">
|
<div class="right-controls">
|
||||||
<div class="app-actions">
|
<div class="user-menu-wrap">
|
||||||
<button class="action-btn ripple-btn" title="设置" @click="openSettings">
|
<button
|
||||||
<Icon icon="lucide:settings" class="action-icon" />
|
class="user-chip"
|
||||||
|
@click="handleUserClick"
|
||||||
|
@contextmenu.prevent="openUserMenu"
|
||||||
|
@mousedown.left="startUserPress"
|
||||||
|
@mouseup="cancelUserPress"
|
||||||
|
@mouseleave="cancelUserPress"
|
||||||
|
:title="authStore.isLoggedIn ? '账号' : '登录'"
|
||||||
|
>
|
||||||
|
<img v-if="authStore.avatar" :src="authStore.avatar" class="user-avatar" alt="" />
|
||||||
|
<span v-else class="user-avatar fallback">
|
||||||
|
<Icon icon="lucide:user" />
|
||||||
|
</span>
|
||||||
|
<span class="user-copy">
|
||||||
|
<strong>{{ authStore.displayName }}</strong>
|
||||||
|
<small>{{ authStore.isLoggedIn ? '云端已连接' : '点击登录' }}</small>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="showUserMenu" class="user-menu" @click.stop>
|
||||||
|
<button @click="openProfileEdit">
|
||||||
|
<Icon icon="lucide:pencil" />
|
||||||
|
<span>编辑资料</span>
|
||||||
|
</button>
|
||||||
|
<button class="danger" @click="logout">
|
||||||
|
<Icon icon="lucide:log-out" />
|
||||||
|
<span>退出账号</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="action-btn" title="设置" @click="openSettings">
|
||||||
|
<Icon icon="lucide:settings" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<div class="window-actions">
|
<div class="window-actions">
|
||||||
<button class="win-btn minimize" @click="handleMinimize" title="最小化">
|
<button class="win-btn" @click="handleMinimize" title="最小化">
|
||||||
<Icon icon="lucide:minus" class="win-icon" />
|
<Icon icon="lucide:minus" />
|
||||||
</button>
|
</button>
|
||||||
|
<button class="win-btn" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
|
||||||
<button class="win-btn maximize" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
|
<Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" />
|
||||||
<Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" class="win-icon" style="transform: scale(0.8);" />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="win-btn close" @click="handleClose" title="关闭">
|
<button class="win-btn close" @click="handleClose" title="关闭">
|
||||||
<Icon icon="lucide:x" class="win-icon" />
|
<Icon icon="lucide:x" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,282 +78,357 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, inject } from 'vue';
|
import { ref, onMounted, onUnmounted, inject } from 'vue'
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router'
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const isMaximized = ref(false);
|
const authStore = useAuthStore()
|
||||||
const searchQuery = ref('');
|
const isMaximized = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
const ignoreNextUserClick = ref(false)
|
||||||
|
let userPressTimer: number | undefined
|
||||||
|
|
||||||
const goBack = () => router.back();
|
const goBack = () => router.back()
|
||||||
const goForward = () => router.forward();
|
const goForward = () => router.forward()
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (!searchQuery.value.trim()) return;
|
if (!searchQuery.value.trim()) return
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Search',
|
name: 'Search',
|
||||||
query: { q: searchQuery.value }
|
query: { q: searchQuery.value }
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
// Settings
|
const openSettings = inject<() => void>('openSettings', () => {})
|
||||||
const openSettings = inject<() => void>('openSettings', () => {});
|
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
|
||||||
|
|
||||||
// --- 窗口控制逻辑 ---
|
const handleUserClick = () => {
|
||||||
const handleMinimize = () => window.electronAPI?.minimizeWindow();
|
if (ignoreNextUserClick.value) {
|
||||||
|
ignoreNextUserClick.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!authStore.isLoggedIn) {
|
||||||
|
openLoginDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push({ name: 'UserProfile', params: { id: authStore.state.userInfo?.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserMenu = () => {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
showUserMenu.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const startUserPress = () => {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
cancelUserPress()
|
||||||
|
userPressTimer = window.setTimeout(() => {
|
||||||
|
ignoreNextUserClick.value = true
|
||||||
|
showUserMenu.value = true
|
||||||
|
}, 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelUserPress = () => {
|
||||||
|
if (userPressTimer) {
|
||||||
|
window.clearTimeout(userPressTimer)
|
||||||
|
userPressTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUserMenu = (event?: MouseEvent) => {
|
||||||
|
const target = event?.target as HTMLElement | null
|
||||||
|
if (target?.closest('.user-menu-wrap')) return
|
||||||
|
showUserMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openProfileEdit = () => {
|
||||||
|
showUserMenu.value = false
|
||||||
|
if (!authStore.state.userInfo?.id) return
|
||||||
|
router.push({ name: 'UserProfile', params: { id: authStore.state.userInfo.id }, query: { edit: '1' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
showUserMenu.value = false
|
||||||
|
await authStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = () => window.electronAPI?.minimizeWindow()
|
||||||
|
|
||||||
const handleMaximize = async () => {
|
const handleMaximize = async () => {
|
||||||
window.electronAPI?.maximizeWindow();
|
window.electronAPI?.maximizeWindow()
|
||||||
isMaximized.value = !isMaximized.value;
|
isMaximized.value = !isMaximized.value
|
||||||
checkMaximizedState();
|
checkMaximizedState()
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleClose = () => window.electronAPI?.closeWindow();
|
const handleClose = () => window.electronAPI?.closeWindow()
|
||||||
|
|
||||||
const checkMaximizedState = async () => {
|
const checkMaximizedState = async () => {
|
||||||
if (window.electronAPI) {
|
if (window.electronAPI) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
isMaximized.value = await window.electronAPI!.isMaximized();
|
isMaximized.value = await window.electronAPI!.isMaximized()
|
||||||
}, 100);
|
}, 100)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkMaximizedState();
|
checkMaximizedState()
|
||||||
window.addEventListener('resize', checkMaximizedState);
|
window.addEventListener('resize', checkMaximizedState)
|
||||||
});
|
window.addEventListener('click', closeUserMenu)
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', checkMaximizedState);
|
cancelUserPress()
|
||||||
});
|
window.removeEventListener('resize', checkMaximizedState)
|
||||||
|
window.removeEventListener('click', closeUserMenu)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 变量定义 */
|
|
||||||
:root {
|
|
||||||
--radius-soft: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 64px;
|
height: 72px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 16px 0 24px;
|
padding: 0 16px 0 24px;
|
||||||
background-color: var(--color-bg-primary);
|
background: transparent;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 左侧区域 --- */
|
.left-controls,
|
||||||
.left-controls {
|
.right-controls,
|
||||||
|
.nav-group,
|
||||||
|
.window-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-controls {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-controls {
|
||||||
|
gap: 3px;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
display: flex;
|
gap: 8px;
|
||||||
gap: 10px;
|
}
|
||||||
|
|
||||||
|
.nav-btn,
|
||||||
|
.action-btn,
|
||||||
|
.win-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 统一功能按钮(Nav / Settings)--- */
|
|
||||||
.nav-btn,
|
.nav-btn,
|
||||||
.action-btn {
|
.action-btn {
|
||||||
-webkit-app-region: no-drag;
|
width: 36px;
|
||||||
width: 40px;
|
height: 36px;
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px; /* 圆角边框 */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden; /* 关键:裁剪涟漪 */
|
|
||||||
transition:
|
|
||||||
background-color 0.2s ease,
|
|
||||||
border-color 0.2s ease,
|
|
||||||
color 0.2s ease,
|
|
||||||
transform 0.12s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-btn svg,
|
||||||
|
.action-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-btn:hover,
|
.nav-btn:hover,
|
||||||
.action-btn:hover {
|
.action-btn:hover,
|
||||||
background-color: var(--color-bg-tertiary);
|
.win-btn:not(.close):hover {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图标 */
|
.user-chip:hover {
|
||||||
.nav-icon,
|
color: var(--color-text-primary);
|
||||||
.action-icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ripple-btn::after {
|
.search-wrapper,
|
||||||
content: "";
|
.user-menu-wrap,
|
||||||
position: absolute;
|
.user-chip {
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.1); /* 涟漪颜色 */
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translate(-50%, -50%) scale(0); /* 初始不可见 */
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 点击瞬间:迅速放大并显示 */
|
|
||||||
.ripple-btn:active::after {
|
|
||||||
transform: translate(-50%, -50%) scale(2.5);
|
|
||||||
opacity: 1;
|
|
||||||
transition: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 松开后:慢慢淡出 */
|
|
||||||
.ripple-btn:not(:active):after {
|
|
||||||
transform: translate(-50%, -50%) scale(2.5);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.4s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- 搜索框 --- */
|
|
||||||
.search-wrapper {
|
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
width: 260px;
|
width: 286px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
background-color: var(--color-bg-tertiary);
|
background: color-mix(in srgb, var(--color-bg-secondary) 72%, transparent);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid transparent;
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-full);
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
transition: background-color 180ms ease, border-color 180ms ease, width 180ms ease;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container:hover {
|
.search-container:hover,
|
||||||
border-color: var(--color-text-muted);
|
.search-container:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent) 26%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container:focus-within {
|
.search-container:focus-within {
|
||||||
width: 340px;
|
width: 352px;
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-bg-primary) 0%,
|
|
||||||
var(--color-bg-tertiary) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
width: 20px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container:focus-within .search-icon {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input::placeholder {
|
.search-input::placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 右侧区域布局调整 --- */
|
.user-chip {
|
||||||
.right-controls {
|
height: 44px;
|
||||||
|
padding: 0 8px 0 5px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px; /* 减少间距,让设置按钮更靠近右边 */
|
gap: 10px;
|
||||||
height: 100%;
|
color: var(--color-text-secondary);
|
||||||
|
transition: background-color 160ms ease, color 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-actions {
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar.fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-copy strong,
|
||||||
|
.user-copy small {
|
||||||
|
max-width: 112px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-copy strong {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-copy small {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 150;
|
||||||
|
width: 142px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 94%, transparent);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu button:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu button.danger:hover {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background-color: var(--color-border);
|
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--color-accent) 24%, transparent), transparent);
|
||||||
margin: 0 4px; /* 减少分割线左右的间距 */
|
margin: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 窗口控制区 --- */
|
|
||||||
.window-actions {
|
.window-actions {
|
||||||
display: flex;
|
gap: 5px;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.win-btn {
|
.win-btn {
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.win-icon {
|
.win-btn svg {
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
}
|
|
||||||
|
|
||||||
.win-btn:not(.close):hover {
|
|
||||||
background-color: var(--color-bg-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.win-btn:not(.close):active {
|
|
||||||
background-color: var(--color-bg-elevated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.win-btn.close:hover {
|
.win-btn.close:hover {
|
||||||
background-color: #e81123;
|
background: #e81123;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
.win-btn.close:active {
|
|
||||||
background-color: #bf0f1d;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TextMarquee from './TextMarquee.vue';
|
import TextMarquee from './TextMarquee.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
name?: string;
|
name?: string;
|
||||||
artists?: string[];
|
artists?: string[];
|
||||||
album?: string;
|
album?: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
299
src/renderer/src/components/player/PlayerQueueList.vue
Normal file
299
src/renderer/src/components/player/PlayerQueueList.vue
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<div class="queue-list" :class="variant">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in playlist"
|
||||||
|
:key="`${song.id}:${index}`"
|
||||||
|
class="queue-row"
|
||||||
|
:class="{ active: index === currentIndex, dragging: index === draggingIndex }"
|
||||||
|
:data-queue-index="index"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="playQueueItem(index)"
|
||||||
|
@keydown.enter="playQueueItem(index)"
|
||||||
|
@keydown.space.prevent="playQueueItem(index)"
|
||||||
|
@pointerdown="startPress($event, index)"
|
||||||
|
>
|
||||||
|
<div class="queue-index">
|
||||||
|
<span v-if="index === currentIndex" class="playing-indicator">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
<img v-if="song.picUrl" :src="song.picUrl" class="queue-cover" alt="" />
|
||||||
|
<div v-else class="queue-cover queue-cover-placeholder">
|
||||||
|
<Icon icon="lucide:music" />
|
||||||
|
</div>
|
||||||
|
<div class="queue-info">
|
||||||
|
<div class="queue-name">{{ song.name }}</div>
|
||||||
|
<div class="queue-artist">{{ song.artist }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="queue-duration">{{ song.duration || '--:--' }}</div>
|
||||||
|
<button class="queue-remove" title="移出播放列表" @click.stop="removeQueueItem(index)">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { usePlayerStore } from '../../stores/player'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
variant?: 'popover' | 'fullscreen'
|
||||||
|
}>(), {
|
||||||
|
variant: 'popover',
|
||||||
|
})
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const { playlist, currentIndex } = storeToRefs(playerStore)
|
||||||
|
const draggingIndex = ref<number | null>(null)
|
||||||
|
const suppressClick = ref(false)
|
||||||
|
let pressTimer: number | undefined
|
||||||
|
|
||||||
|
const clearPressTimer = () => {
|
||||||
|
if (pressTimer !== undefined) {
|
||||||
|
window.clearTimeout(pressTimer)
|
||||||
|
pressTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPress = (event: PointerEvent, index: number) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.closest('.queue-remove')) return
|
||||||
|
|
||||||
|
clearPressTimer()
|
||||||
|
pressTimer = window.setTimeout(() => {
|
||||||
|
draggingIndex.value = index
|
||||||
|
suppressClick.value = true
|
||||||
|
document.body.classList.add('queue-dragging')
|
||||||
|
window.addEventListener('pointermove', handleDragMove)
|
||||||
|
window.addEventListener('pointerup', stopDrag, { once: true })
|
||||||
|
window.addEventListener('pointercancel', stopDrag, { once: true })
|
||||||
|
}, 260)
|
||||||
|
|
||||||
|
window.addEventListener('pointerup', clearPressTimer, { once: true })
|
||||||
|
window.addEventListener('pointercancel', clearPressTimer, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragMove = (event: PointerEvent) => {
|
||||||
|
if (draggingIndex.value === null) return
|
||||||
|
const element = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null
|
||||||
|
const row = element?.closest<HTMLElement>('[data-queue-index]')
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const nextIndex = Number(row.dataset.queueIndex)
|
||||||
|
if (!Number.isInteger(nextIndex) || nextIndex === draggingIndex.value) return
|
||||||
|
playerStore.moveQueueItem(draggingIndex.value, nextIndex)
|
||||||
|
draggingIndex.value = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrag = async () => {
|
||||||
|
clearPressTimer()
|
||||||
|
window.removeEventListener('pointermove', handleDragMove)
|
||||||
|
document.body.classList.remove('queue-dragging')
|
||||||
|
draggingIndex.value = null
|
||||||
|
await nextTick()
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressClick.value = false
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playQueueItem = (index: number) => {
|
||||||
|
if (suppressClick.value) return
|
||||||
|
playerStore.playQueueIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQueueItem = (index: number) => {
|
||||||
|
playerStore.removeFromQueue(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPressTimer()
|
||||||
|
window.removeEventListener('pointermove', handleDragMove)
|
||||||
|
document.body.classList.remove('queue-dragging')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--album-color, var(--color-accent)) 24%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 24%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 42px minmax(0, 1fr) auto 30px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row:hover,
|
||||||
|
.queue-row.active {
|
||||||
|
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 11%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row.dragging {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-index {
|
||||||
|
width: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-cover {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 11%, var(--color-bg-secondary));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-cover-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-name,
|
||||||
|
.queue-artist {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-artist {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-remove {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-row:hover .queue-remove,
|
||||||
|
.queue-row.active .queue-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 12%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator {
|
||||||
|
height: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator .bar {
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--album-color, var(--color-accent)) 70%, var(--color-accent));
|
||||||
|
animation: queueBars 0.82s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator .bar:nth-child(1) {
|
||||||
|
height: 8px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator .bar:nth-child(2) {
|
||||||
|
height: 14px;
|
||||||
|
animation-delay: 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-indicator .bar:nth-child(3) {
|
||||||
|
height: 10px;
|
||||||
|
animation-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen {
|
||||||
|
--color-text-primary: rgba(255, 255, 255, 0.95);
|
||||||
|
--color-text-secondary: rgba(255, 255, 255, 0.72);
|
||||||
|
--color-text-muted: rgba(255, 255, 255, 0.45);
|
||||||
|
--color-bg-secondary: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes queueBars {
|
||||||
|
0%, 100% { transform: scaleY(0.55); }
|
||||||
|
50% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.playing-indicator .bar {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.queue-dragging) {
|
||||||
|
user-select: none;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onUnmounted } from 'vue';
|
import { ref, onUnmounted } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
className?: string;
|
className?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -44,19 +44,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background-color: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
overflow: hidden; /* 确保整个应用不会出现双重滚动条 */
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
/* Dynamic Spacing for PlayerBar */
|
|
||||||
.layout-sidebar,
|
|
||||||
.content-area {
|
|
||||||
transition: padding-bottom 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-layout.has-player .layout-sidebar,
|
|
||||||
.main-layout.has-player .content-area {
|
|
||||||
padding-bottom: 80px; /* PlayerBar Height */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
@@ -66,14 +55,25 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
background:
|
||||||
|
var(--color-atmosphere-gradient) top / 100% 240px no-repeat,
|
||||||
|
var(--color-bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0;
|
padding: 0 0 20px;
|
||||||
background-color: var(--color-bg-primary);
|
background: transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
scroll-padding-bottom: 20px;
|
||||||
|
transition: padding-bottom 0.24s ease, scroll-padding-bottom 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-layout.has-player .page-content {
|
||||||
|
padding-bottom: 128px;
|
||||||
|
scroll-padding-bottom: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式优化 */
|
/* 滚动条样式优化 */
|
||||||
@@ -104,4 +104,4 @@ body {
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,6 +30,46 @@ const router = createRouter({
|
|||||||
name: 'Recent',
|
name: 'Recent',
|
||||||
component: () => import('./views/Playlist.vue')
|
component: () => import('./views/Playlist.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/together',
|
||||||
|
name: 'ListenTogether',
|
||||||
|
component: () => import('./views/ListenTogether.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/listen-stats',
|
||||||
|
name: 'ListenStats',
|
||||||
|
component: () => import('./views/ListenStats.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/listen-rank',
|
||||||
|
name: 'ListenRank',
|
||||||
|
component: () => import('./views/ListenRank.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/playlist-square',
|
||||||
|
name: 'PlaylistSquare',
|
||||||
|
component: () => import('./views/PlaylistSquare.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/:id',
|
||||||
|
name: 'UserProfile',
|
||||||
|
component: () => import('./views/UserProfile.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user/:id/liked',
|
||||||
|
name: 'UserLikedPlaylist',
|
||||||
|
component: () => import('./views/Playlist.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/playlist/:scope/:id',
|
||||||
|
name: 'PlaylistDetail',
|
||||||
|
component: () => import('./views/Playlist.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/plugin/:pluginId/:kind/:id',
|
||||||
|
name: 'PluginCollection',
|
||||||
|
component: () => import('./views/Playlist.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/search',
|
path: '/search',
|
||||||
name: 'Search',
|
name: 'Search',
|
||||||
|
|||||||
90
src/renderer/src/stores/auth.ts
Normal file
90
src/renderer/src/stores/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar?: string | null
|
||||||
|
nickname?: string | null
|
||||||
|
gender?: string | null
|
||||||
|
region?: string | null
|
||||||
|
intro?: string | null
|
||||||
|
birthday?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
exp: number
|
||||||
|
userInfo: UserInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyState: AuthState = {
|
||||||
|
accessToken: '',
|
||||||
|
refreshToken: '',
|
||||||
|
exp: 0,
|
||||||
|
userInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeAuthListener: (() => void) | undefined
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const state = ref<AuthState>({ ...emptyState })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => Boolean(state.value.accessToken && state.value.userInfo?.id))
|
||||||
|
const displayName = computed(() => state.value.userInfo?.nickname || state.value.userInfo?.username || '未登录')
|
||||||
|
const avatar = computed(() => state.value.userInfo?.avatar || '')
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
state.value = await window.electronAPI.auth.getState()
|
||||||
|
if (!removeAuthListener) {
|
||||||
|
removeAuthListener = window.electronAPI.auth.onChanged((payload) => {
|
||||||
|
state.value = payload.state || { ...emptyState }
|
||||||
|
if (payload.status === 'success') ElMessage.success('登录成功')
|
||||||
|
if (payload.status === 'error') ElMessage.error(payload.message || '登录失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (state.value.accessToken) {
|
||||||
|
try {
|
||||||
|
state.value = await window.electronAPI.auth.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Auth] refresh failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (forcePrompt = false) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await window.electronAPI.auth.login(forcePrompt)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
state.value = await window.electronAPI.auth.logout()
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyUserInfo = (userInfo: UserInfo) => {
|
||||||
|
state.value = {
|
||||||
|
...state.value,
|
||||||
|
userInfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
isLoggedIn,
|
||||||
|
displayName,
|
||||||
|
avatar,
|
||||||
|
init,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
applyUserInfo,
|
||||||
|
}
|
||||||
|
})
|
||||||
296
src/renderer/src/stores/listenTogether.ts
Normal file
296
src/renderer/src/stores/listenTogether.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { Song } from '../types/song'
|
||||||
|
import { calibrateTime, calibratedNow } from './timeCalibrator'
|
||||||
|
|
||||||
|
type RoomMode = 'dual' | 'multi'
|
||||||
|
type ServerAction =
|
||||||
|
| 'ROOM_CREATED'
|
||||||
|
| 'SYNC_PROPERTIES'
|
||||||
|
| 'PING'
|
||||||
|
| 'UPDATE'
|
||||||
|
| 'SUCCESS'
|
||||||
|
| 'ROOM_CLOSED'
|
||||||
|
| 'ERROR'
|
||||||
|
|
||||||
|
interface ServerMessage {
|
||||||
|
action: ServerAction
|
||||||
|
data?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
|
||||||
|
export const useListenTogetherStore = defineStore('listenTogether', () => {
|
||||||
|
const connecting = ref(false)
|
||||||
|
const connected = ref(false)
|
||||||
|
const roomId = ref('')
|
||||||
|
const mode = ref<RoomMode>('multi')
|
||||||
|
const permissionLevel = ref(0)
|
||||||
|
const userList = ref<string[]>([])
|
||||||
|
const allPermissions = ref<Record<string, number>>({})
|
||||||
|
const listVersion = ref(0)
|
||||||
|
const lastError = ref('')
|
||||||
|
const isApplyingRemote = ref(false)
|
||||||
|
|
||||||
|
const canControl = computed(() => connected.value && permissionLevel.value >= 1)
|
||||||
|
const isHost = computed(() => connected.value && permissionLevel.value >= 2)
|
||||||
|
|
||||||
|
const resetRoomState = () => {
|
||||||
|
connected.value = false
|
||||||
|
connecting.value = false
|
||||||
|
roomId.value = ''
|
||||||
|
permissionLevel.value = 0
|
||||||
|
userList.value = []
|
||||||
|
allPermissions.value = {}
|
||||||
|
listVersion.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRaw = (action: string, data: Record<string, any> = {}) => {
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) return false
|
||||||
|
socket.send(JSON.stringify({ action, data }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAction = (action: string, data: Record<string, any> = {}) => {
|
||||||
|
if (!connected.value && action !== 'PONG') return false
|
||||||
|
return sendRaw(action, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlayer = async () => {
|
||||||
|
const mod = await import('./player')
|
||||||
|
return mod.usePlayerStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackPayload = async () => {
|
||||||
|
const player = await getPlayer()
|
||||||
|
return {
|
||||||
|
currentMs: Math.max(0, Math.floor(player.currentTime || 0)),
|
||||||
|
currentIndex: Math.max(0, player.currentIndex),
|
||||||
|
timestamp: calibratedNow(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyRemoteState = async (data: any) => {
|
||||||
|
const player = await getPlayer()
|
||||||
|
isApplyingRemote.value = true
|
||||||
|
try {
|
||||||
|
await player.applyTogetherState(data)
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
isApplyingRemote.value = false
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendCurrentSnapshot = async () => {
|
||||||
|
if (!canControl.value) return
|
||||||
|
const player = await getPlayer()
|
||||||
|
if (player.playlist.length > 0) {
|
||||||
|
sendAction('SET', {
|
||||||
|
baseListVersion: listVersion.value,
|
||||||
|
list: player.playlist,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const payload = await playbackPayload()
|
||||||
|
sendAction('SEEK', payload)
|
||||||
|
sendAction(player.isPlaying ? 'PLAY' : 'PAUSE', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = async (message: ServerMessage) => {
|
||||||
|
const data = message.data || {}
|
||||||
|
|
||||||
|
switch (message.action) {
|
||||||
|
case 'ROOM_CREATED':
|
||||||
|
roomId.value = data.room_id || ''
|
||||||
|
connected.value = true
|
||||||
|
connecting.value = false
|
||||||
|
permissionLevel.value = 2
|
||||||
|
lastError.value = ''
|
||||||
|
ElMessage.success('一起听房间已创建')
|
||||||
|
await sendCurrentSnapshot()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'SYNC_PROPERTIES':
|
||||||
|
permissionLevel.value = Number(data.permission_level ?? permissionLevel.value)
|
||||||
|
mode.value = data.mode === 'dual' ? 'dual' : 'multi'
|
||||||
|
userList.value = Array.isArray(data.user_list) ? data.user_list : []
|
||||||
|
allPermissions.value = data.all_permissions || {}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'PING':
|
||||||
|
sendRaw('PONG')
|
||||||
|
if (isHost.value) {
|
||||||
|
sendAction('SYNC', await playbackPayload())
|
||||||
|
} else {
|
||||||
|
await applyRemoteState(data)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'UPDATE':
|
||||||
|
if (typeof data.listVersion === 'number') listVersion.value = data.listVersion
|
||||||
|
await applyRemoteState(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'SUCCESS':
|
||||||
|
if (typeof data.listVersion === 'number') listVersion.value = data.listVersion
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ROOM_CLOSED':
|
||||||
|
ElMessage.info('一起听房间已关闭')
|
||||||
|
disconnect(false)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ERROR':
|
||||||
|
lastError.value = data.msg || '一起听同步失败'
|
||||||
|
ElMessage.warning(lastError.value)
|
||||||
|
if (data.code === '409') sendAction('GET')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = async (params: Record<string, string>) => {
|
||||||
|
disconnect(false)
|
||||||
|
connecting.value = true
|
||||||
|
lastError.value = ''
|
||||||
|
|
||||||
|
calibrateTime().catch(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await window.electronAPI.listenTogether.getWsUrl(params)
|
||||||
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
handleMessage(JSON.parse(event.data)).catch(console.error)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ListenTogether] Invalid message:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
lastError.value = '一起听连接失败'
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
socket = null
|
||||||
|
resetRoomState()
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
connecting.value = false
|
||||||
|
lastError.value = error?.message || '一起听连接失败'
|
||||||
|
ElMessage.error(lastError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRoom = async (nextMode: RoomMode) => {
|
||||||
|
mode.value = nextMode
|
||||||
|
await connect({ mode: nextMode })
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinRoom = async (targetRoomId: string) => {
|
||||||
|
const normalized = targetRoomId.trim()
|
||||||
|
if (!normalized) return
|
||||||
|
await connect({ room_id: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
const disconnect = (notifyServer = true) => {
|
||||||
|
if (notifyServer && socket?.readyState === WebSocket.OPEN && isHost.value) {
|
||||||
|
sendRaw('CLOSE_ROOM')
|
||||||
|
}
|
||||||
|
if (socket) {
|
||||||
|
socket.close(1000)
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
resetRoomState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendPlayback = async (playing: boolean) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction(playing ? 'PLAY' : 'PAUSE', await playbackPayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendSeek = async (currentMs: number, currentIndex: number) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction('SEEK', {
|
||||||
|
currentMs: Math.max(0, Math.floor(currentMs || 0)),
|
||||||
|
currentIndex: Math.max(0, currentIndex),
|
||||||
|
timestamp: calibratedNow(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendSetList = (list: Song[]) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction('SET', {
|
||||||
|
baseListVersion: listVersion.value,
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAddSong = (song: Song) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction('ADD', {
|
||||||
|
baseListVersion: listVersion.value,
|
||||||
|
song,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendInsertSong = (song: Song, index: number) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction('INSERT', {
|
||||||
|
baseListVersion: listVersion.value,
|
||||||
|
index,
|
||||||
|
song,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRemoveSong = (song: Song, index: number, currentIndex: number) => {
|
||||||
|
if (!canControl.value || isApplyingRemote.value) return
|
||||||
|
sendAction('REMOVE', {
|
||||||
|
baseListVersion: listVersion.value,
|
||||||
|
song,
|
||||||
|
index,
|
||||||
|
currentIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePermission = (targetUserId: string, level: 0 | 1) => {
|
||||||
|
if (!isHost.value) return
|
||||||
|
sendAction('CHANGE_PERMISSION', {
|
||||||
|
target_user_id: targetUserId,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connecting,
|
||||||
|
connected,
|
||||||
|
roomId,
|
||||||
|
mode,
|
||||||
|
permissionLevel,
|
||||||
|
userList,
|
||||||
|
allPermissions,
|
||||||
|
listVersion,
|
||||||
|
lastError,
|
||||||
|
isApplyingRemote,
|
||||||
|
canControl,
|
||||||
|
isHost,
|
||||||
|
createRoom,
|
||||||
|
joinRoom,
|
||||||
|
disconnect,
|
||||||
|
sendCurrentSnapshot,
|
||||||
|
sendPlayback,
|
||||||
|
sendSeek,
|
||||||
|
sendSetList,
|
||||||
|
sendAddSong,
|
||||||
|
sendInsertSong,
|
||||||
|
sendRemoveSong,
|
||||||
|
changePermission,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@ import { ref, shallowRef, watch } from 'vue';
|
|||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import type { Song } from '../types/song';
|
import type { Song } from '../types/song';
|
||||||
import { parseLyric } from '../utils/lyricUtil'
|
import { parseLyric } from '../utils/lyricUtil'
|
||||||
|
import { useListenTogetherStore } from './listenTogether';
|
||||||
|
import { calibratedNow } from './timeCalibrator';
|
||||||
export enum PlayMode {
|
export enum PlayMode {
|
||||||
List = 'list',
|
List = 'list',
|
||||||
Single = 'single',
|
Single = 'single',
|
||||||
@@ -55,11 +57,20 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const playMode = ref<PlayMode>(PlayMode.List);
|
const playMode = ref<PlayMode>(PlayMode.List);
|
||||||
const savedAddMode = localStorage.getItem('qz-player-add-mode');
|
const savedAddMode = localStorage.getItem('qz-player-add-mode');
|
||||||
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
||||||
|
const openPlayerOnSongClick = ref(false);
|
||||||
|
|
||||||
// Error Handling
|
// Error Handling
|
||||||
const playErrorCount = ref(0);
|
const playErrorCount = ref(0);
|
||||||
const MAX_RETRY_COUNT = 3;
|
const currentSongRetryCount = ref(0);
|
||||||
const hasRetriedWithFreshUrl = ref(false);
|
const MAX_SONG_RETRY_COUNT = 1;
|
||||||
|
const MAX_CONSECUTIVE_SKIP_COUNT = 3;
|
||||||
|
let handlingPlayError = false;
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 300_000;
|
||||||
|
const heartbeatDuration = ref(0);
|
||||||
|
let lastHeartbeatTick = Date.now();
|
||||||
|
let heartbeatSending = false;
|
||||||
|
let lastTaskbarProgress = -1;
|
||||||
|
let lastTaskbarMode: 'normal' | 'paused' = 'normal';
|
||||||
|
|
||||||
// Lyrics State
|
// Lyrics State
|
||||||
const lyrics = shallowRef<{ lines: any[] }>({ lines: [] });
|
const lyrics = shallowRef<{ lines: any[] }>({ lines: [] });
|
||||||
@@ -93,14 +104,99 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listenTogether = () => {
|
||||||
|
try {
|
||||||
|
return useListenTogetherStore();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncTaskbarProgress = () => {
|
||||||
|
if (!window.electronAPI?.setTaskbarProgress) return;
|
||||||
|
|
||||||
|
if (!currentSong.value || duration.value <= 0) {
|
||||||
|
if (lastTaskbarProgress !== -1) {
|
||||||
|
lastTaskbarProgress = -1;
|
||||||
|
window.electronAPI.setTaskbarProgress(-1).catch(console.warn);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.max(0, Math.min(1, currentTime.value / duration.value));
|
||||||
|
const mode: 'normal' | 'paused' = isPlaying.value ? 'normal' : 'paused';
|
||||||
|
if (Math.abs(progress - lastTaskbarProgress) < 0.002 && mode === lastTaskbarMode) return;
|
||||||
|
|
||||||
|
lastTaskbarProgress = progress;
|
||||||
|
lastTaskbarMode = mode;
|
||||||
|
window.electronAPI.setTaskbarProgress(progress, mode).catch(console.warn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyTogetherSetList = () => {
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendSetList([...playlist.value]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyTogetherSeek = () => {
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendSeek(currentTime.value, currentIndex.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyTogetherPlayback = (playing = isPlaying.value) => {
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendPlayback(playing);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickHeartbeat = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const delta = now - lastHeartbeatTick;
|
||||||
|
lastHeartbeatTick = now;
|
||||||
|
|
||||||
|
if (!isPlaying.value || !currentSong.value) return;
|
||||||
|
heartbeatDuration.value += delta;
|
||||||
|
|
||||||
|
if (heartbeatDuration.value < HEARTBEAT_INTERVAL_MS || heartbeatSending) return;
|
||||||
|
heartbeatSending = true;
|
||||||
|
const durationToSend = HEARTBEAT_INTERVAL_MS;
|
||||||
|
heartbeatDuration.value = Math.max(0, heartbeatDuration.value - durationToSend);
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.heartbeat?.sendPc(durationToSend, calibratedNow());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Heartbeat] PC heartbeat failed:', error);
|
||||||
|
} finally {
|
||||||
|
heartbeatSending = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.electronAPI?.settings?.getAll?.()
|
||||||
|
.then((settings) => {
|
||||||
|
openPlayerOnSongClick.value = Boolean(settings.openPlayerOnSongClick);
|
||||||
|
})
|
||||||
|
.catch((error) => console.warn('[Player] Failed to load click preference:', error));
|
||||||
|
window.addEventListener('qz-open-player-on-song-click-changed', (event) => {
|
||||||
|
openPlayerOnSongClick.value = Boolean((event as CustomEvent<boolean>).detail);
|
||||||
|
});
|
||||||
|
window.setInterval(() => {
|
||||||
|
tickHeartbeat().catch(console.error);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
const setPlaylist = async (list: any[], startIndex = 0) => {
|
const setPlaylist = async (list: any[], startIndex = 0) => {
|
||||||
// Legacy support or direct set
|
// Legacy support or direct set
|
||||||
playlist.value = list;
|
playlist.value = list;
|
||||||
currentIndex.value = startIndex;
|
currentIndex.value = startIndex;
|
||||||
|
notifyTogetherSetList();
|
||||||
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
||||||
await playSong(list[startIndex]);
|
await playSong(list[startIndex], true, startIndex);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,17 +221,26 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
playlist.value.push(song);
|
playlist.value.push(song);
|
||||||
const newIndex = playlist.value.length - 1;
|
const newIndex = playlist.value.length - 1;
|
||||||
currentIndex.value = newIndex;
|
currentIndex.value = newIndex;
|
||||||
await playSong(song);
|
notifyTogetherSetList();
|
||||||
|
await playSong(song, true, newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openPlayerOnSongClick.value && !isPlayerFullScreen.value) {
|
||||||
|
toggleFullScreen();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSong = async (song: Song, autoPlay = true) => {
|
const playSong = async (song: Song, autoPlay = true, queueIndex?: number) => {
|
||||||
if (!song) return;
|
if (!song) return;
|
||||||
console.log(song);
|
console.log(song);
|
||||||
currentSong.value = song;
|
currentSong.value = song;
|
||||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
if (typeof queueIndex === 'number') {
|
||||||
if (foundIndex !== -1) {
|
currentIndex.value = queueIndex;
|
||||||
currentIndex.value = foundIndex;
|
} else {
|
||||||
|
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||||
|
if (foundIndex !== -1) {
|
||||||
|
currentIndex.value = foundIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await activateDummyAudio();
|
await activateDummyAudio();
|
||||||
@@ -153,8 +258,6 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
if (playUrl) {
|
if (playUrl) {
|
||||||
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
||||||
// Reset retry flag for new playback attempt
|
|
||||||
hasRetriedWithFreshUrl.value = false;
|
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.qzplayer.load(playUrl);
|
await window.electronAPI.qzplayer.load(playUrl);
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
@@ -170,20 +273,31 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
syncDummyAudioState(false);
|
syncDummyAudioState(false);
|
||||||
}
|
}
|
||||||
song.url = playUrl;
|
song.url = playUrl;
|
||||||
|
currentSongRetryCount.value = 0;
|
||||||
|
playErrorCount.value = 0;
|
||||||
|
notifyTogetherSeek();
|
||||||
|
notifyTogetherPlayback(autoPlay);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// IPC call failed (rare), handle sync error
|
// IPC call failed (rare), handle sync error
|
||||||
console.error("IPC Play request failed:", e);
|
console.error("IPC Play request failed:", e);
|
||||||
if (autoPlay) handlePlayError().then();
|
if (autoPlay) await handlePlayError();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("Song has no URL");
|
console.warn("Song has no URL");
|
||||||
if (autoPlay) handlePlayError().then();
|
if (autoPlay) await handlePlayError();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchLyrics = async (song: Song) => {
|
const fetchLyrics = async (song: Song) => {
|
||||||
lyrics.value = { lines: [] }; // Reset
|
lyrics.value = { lines: [] }; // Reset
|
||||||
if (!song || !song.id) return;
|
if (!song || !song.id) return;
|
||||||
|
if (song.type === 'Local' || song.source === 'local') {
|
||||||
|
const localLyric = typeof song.lyric === 'string' ? song.lyric.trim() : '';
|
||||||
|
if (localLyric) {
|
||||||
|
lyrics.value = { lines: parseLyric(localLyric) };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
//Check if plugin API exists
|
//Check if plugin API exists
|
||||||
if (window.electronAPI?.plugin?.getLyric) {
|
if (window.electronAPI?.plugin?.getLyric) {
|
||||||
@@ -231,7 +345,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
nextIndex = (currentIndex.value + 1) % playlist.value.length;
|
nextIndex = (currentIndex.value + 1) % playlist.value.length;
|
||||||
}
|
}
|
||||||
currentIndex.value = nextIndex;
|
currentIndex.value = nextIndex;
|
||||||
await playSong(playlist.value[nextIndex]);
|
await playSong(playlist.value[nextIndex], true, nextIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = async () => {
|
const prev = async () => {
|
||||||
@@ -243,32 +357,188 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
|
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
|
||||||
}
|
}
|
||||||
currentIndex.value = prevIndex;
|
currentIndex.value = prevIndex;
|
||||||
await playSong(playlist.value[prevIndex]);
|
await playSong(playlist.value[prevIndex], true, prevIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayError = async () => {
|
const playQueueIndex = async (index: number) => {
|
||||||
// Proxy handles refreshing internally, so we rely on qzplayer error/retry for now.
|
if (index < 0 || index >= playlist.value.length) return;
|
||||||
// Or we could implement a mechanism to tell proxy to invalidate cache if this fails repeatedly (future work).
|
currentIndex.value = index;
|
||||||
|
await playSong(playlist.value[index], true, index);
|
||||||
|
};
|
||||||
|
|
||||||
// Normal error handling
|
const playNextInQueue = async (song: Song) => {
|
||||||
playErrorCount.value++;
|
if (!song) return;
|
||||||
hasRetriedWithFreshUrl.value = false;
|
if (playlist.value.length === 0) {
|
||||||
|
playlist.value = [song];
|
||||||
|
currentIndex.value = 0;
|
||||||
|
notifyTogetherSetList();
|
||||||
|
await playSong(song, true, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertIndex = currentIndex.value >= 0 ? currentIndex.value + 1 : playlist.value.length;
|
||||||
|
playlist.value.splice(insertIndex, 0, song);
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendInsertSong(song, insertIndex);
|
||||||
|
}
|
||||||
|
ElMessage.success('已设为下一首播放');
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendToQueue = async (song: Song) => {
|
||||||
|
if (!song) return;
|
||||||
|
playlist.value.push(song);
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendAddSong(song);
|
||||||
|
}
|
||||||
|
if (!currentSong.value || currentIndex.value === -1) {
|
||||||
|
currentIndex.value = playlist.value.length - 1;
|
||||||
|
await playSong(song, true, currentIndex.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.success('已添加到播放队列');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromQueue = async (index: number) => {
|
||||||
|
if (index < 0 || index >= playlist.value.length) return;
|
||||||
|
const removedCurrent = index === currentIndex.value;
|
||||||
|
const removedSong = playlist.value[index];
|
||||||
|
playlist.value.splice(index, 1);
|
||||||
|
const together = listenTogether();
|
||||||
|
if (together?.canControl && !together.isApplyingRemote) {
|
||||||
|
together.sendRemoveSong(removedSong, index, currentIndex.value);
|
||||||
|
}
|
||||||
|
|
||||||
if (playlist.value.length === 0) {
|
if (playlist.value.length === 0) {
|
||||||
|
currentIndex.value = -1;
|
||||||
|
currentSong.value = null;
|
||||||
isPlaying.value = false;
|
isPlaying.value = false;
|
||||||
playErrorCount.value = 0;
|
await window.electronAPI.qzplayer.pause();
|
||||||
syncDummyAudioState(false);
|
syncDummyAudioState(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (playErrorCount.value >= MAX_RETRY_COUNT) {
|
|
||||||
window.electronAPI.qzplayer.pause().then();
|
if (index < currentIndex.value) {
|
||||||
|
currentIndex.value -= 1;
|
||||||
|
} else if (removedCurrent) {
|
||||||
|
currentIndex.value = Math.min(index, playlist.value.length - 1);
|
||||||
|
await playSong(playlist.value[currentIndex.value], true, currentIndex.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveQueueItem = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (
|
||||||
|
fromIndex === toIndex ||
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= playlist.value.length ||
|
||||||
|
toIndex >= playlist.value.length
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [song] = playlist.value.splice(fromIndex, 1);
|
||||||
|
playlist.value.splice(toIndex, 0, song);
|
||||||
|
|
||||||
|
if (currentIndex.value === fromIndex) {
|
||||||
|
currentIndex.value = toIndex;
|
||||||
|
} else if (fromIndex < currentIndex.value && toIndex >= currentIndex.value) {
|
||||||
|
currentIndex.value -= 1;
|
||||||
|
} else if (fromIndex > currentIndex.value && toIndex <= currentIndex.value) {
|
||||||
|
currentIndex.value += 1;
|
||||||
|
}
|
||||||
|
notifyTogetherSetList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTogetherState = async (data: any) => {
|
||||||
|
const nextList = Array.isArray(data.list) ? data.list as Song[] : playlist.value;
|
||||||
|
const nextIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(Number(data.currentIndex ?? currentIndex.value) || 0, Math.max(0, nextList.length - 1))
|
||||||
|
);
|
||||||
|
const nextMs = Math.max(0, Number(data.currentMs ?? currentTime.value) || 0);
|
||||||
|
const shouldPlay = Boolean(data.playing);
|
||||||
|
|
||||||
|
if (Array.isArray(data.list)) {
|
||||||
|
playlist.value = nextList;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextList.length === 0) {
|
||||||
|
playlist.value = [];
|
||||||
|
currentIndex.value = -1;
|
||||||
|
currentSong.value = null;
|
||||||
isPlaying.value = false;
|
isPlaying.value = false;
|
||||||
ElMessage.error('连续多次播放失败,已停止播放');
|
await window.electronAPI.qzplayer.pause();
|
||||||
playErrorCount.value = 0;
|
|
||||||
syncDummyAudioState(false);
|
syncDummyAudioState(false);
|
||||||
} else {
|
return;
|
||||||
ElMessage.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`);
|
}
|
||||||
next(false)
|
|
||||||
|
const nextSong = nextList[nextIndex];
|
||||||
|
const needsLoad =
|
||||||
|
!currentSong.value ||
|
||||||
|
currentSong.value.id !== nextSong.id ||
|
||||||
|
currentSong.value.source !== nextSong.source ||
|
||||||
|
currentIndex.value !== nextIndex;
|
||||||
|
|
||||||
|
currentIndex.value = nextIndex;
|
||||||
|
if (needsLoad) {
|
||||||
|
await playSong(nextSong, shouldPlay, nextIndex);
|
||||||
|
} else if (shouldPlay !== isPlaying.value) {
|
||||||
|
if (shouldPlay) {
|
||||||
|
await window.electronAPI.qzplayer.play();
|
||||||
|
isPlaying.value = true;
|
||||||
|
syncDummyAudioState(true);
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.qzplayer.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
syncDummyAudioState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs((currentTime.value || 0) - nextMs) > 1200) {
|
||||||
|
await window.electronAPI.qzplayer.seek(nextMs);
|
||||||
|
currentTime.value = nextMs;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayError = async () => {
|
||||||
|
if (handlingPlayError) return;
|
||||||
|
handlingPlayError = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playlist.value.length === 0) {
|
||||||
|
isPlaying.value = false;
|
||||||
|
playErrorCount.value = 0;
|
||||||
|
currentSongRetryCount.value = 0;
|
||||||
|
syncDummyAudioState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedSong = currentSong.value;
|
||||||
|
if (failedSong && currentSongRetryCount.value < MAX_SONG_RETRY_COUNT) {
|
||||||
|
currentSongRetryCount.value++;
|
||||||
|
ElMessage.warning('\u64ad\u653e\u5931\u8d25\uff0c\u6b63\u5728\u91cd\u8bd5\u5f53\u524d\u6b4c\u66f2');
|
||||||
|
handlingPlayError = false;
|
||||||
|
await playSong(failedSong, true, currentIndex.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSongRetryCount.value = 0;
|
||||||
|
playErrorCount.value++;
|
||||||
|
if (playErrorCount.value >= MAX_CONSECUTIVE_SKIP_COUNT) {
|
||||||
|
await window.electronAPI.qzplayer.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
ElMessage.error('\u8fde\u7eed 3 \u9996\u6b4c\u66f2\u64ad\u653e\u5931\u8d25\uff0c\u5df2\u6682\u505c\u64ad\u653e');
|
||||||
|
playErrorCount.value = 0;
|
||||||
|
syncDummyAudioState(false);
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(`\u5f53\u524d\u6b4c\u66f2\u4ecd\u65e0\u6cd5\u64ad\u653e\uff0c\u5df2\u8df3\u8fc7 (${playErrorCount.value}/${MAX_CONSECUTIVE_SKIP_COUNT})`);
|
||||||
|
handlingPlayError = false;
|
||||||
|
await next(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
handlingPlayError = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,6 +550,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const isPaused = data.data;
|
const isPaused = data.data;
|
||||||
isPlaying.value = !isPaused;
|
isPlaying.value = !isPaused;
|
||||||
syncDummyAudioState(!isPaused);
|
syncDummyAudioState(!isPaused);
|
||||||
|
notifyTogetherPlayback(!isPaused);
|
||||||
}
|
}
|
||||||
if (data.name === 'time-pos') currentTime.value = data.data; //毫秒级
|
if (data.name === 'time-pos') currentTime.value = data.data; //毫秒级
|
||||||
if (data.name === 'duration') duration.value = data.data; //毫秒级
|
if (data.name === 'duration') duration.value = data.data; //毫秒级
|
||||||
@@ -309,6 +580,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
const seek = async (time: number) => {
|
const seek = async (time: number) => {
|
||||||
await window.electronAPI.qzplayer.seek(time);
|
await window.electronAPI.qzplayer.seek(time);
|
||||||
|
currentTime.value = time;
|
||||||
|
notifyTogetherSeek();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMode = () => {
|
const toggleMode = () => {
|
||||||
@@ -318,7 +591,21 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleFullScreen = () => {
|
const toggleFullScreen = () => {
|
||||||
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
const startViewTransition = (document as any).startViewTransition;
|
||||||
|
const toggle = () => {
|
||||||
|
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof startViewTransition === 'function') {
|
||||||
|
try {
|
||||||
|
startViewTransition(toggle);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('View transition failed, falling back to direct player toggle:', error);
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Persistence Listeners
|
// Persistence Listeners
|
||||||
@@ -331,13 +618,15 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
localStorage.setItem('qz-player-add-mode', newMode);
|
localStorage.setItem('qz-player-add-mode', newMode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([currentTime, duration, isPlaying, currentSong], syncTaskbarProgress, { immediate: true });
|
||||||
|
|
||||||
// Restore initial state (without playing)
|
// Restore initial state (without playing)
|
||||||
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
|
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
|
||||||
const restoredSong = playlist.value[currentIndex.value];
|
const restoredSong = playlist.value[currentIndex.value];
|
||||||
if (restoredSong) {
|
if (restoredSong) {
|
||||||
// Use playSong with autoPlay=false to load the song into the engine
|
// Use playSong with autoPlay=false to load the song into the engine
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
playSong(restoredSong, true);
|
playSong(restoredSong, true, currentIndex.value);
|
||||||
fetchLyrics(restoredSong);
|
fetchLyrics(restoredSong);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
@@ -350,12 +639,19 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
duration,
|
duration,
|
||||||
currentTime,
|
currentTime,
|
||||||
playlist,
|
playlist,
|
||||||
|
currentIndex,
|
||||||
playMode,
|
playMode,
|
||||||
loudness,
|
loudness,
|
||||||
spectrum,
|
spectrum,
|
||||||
isPlayerFullScreen,
|
isPlayerFullScreen,
|
||||||
setPlaylist,
|
setPlaylist,
|
||||||
playSong,
|
playSong,
|
||||||
|
playQueueIndex,
|
||||||
|
playNextInQueue,
|
||||||
|
appendToQueue,
|
||||||
|
removeFromQueue,
|
||||||
|
moveQueueItem,
|
||||||
|
applyTogetherState,
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
togglePlay,
|
togglePlay,
|
||||||
|
|||||||
163
src/renderer/src/stores/playlists.ts
Normal file
163
src/renderer/src/stores/playlists.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, toRaw } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { Song } from '../types/song'
|
||||||
|
|
||||||
|
export type PlaylistScope = 'local' | 'cloud' | 'plugin'
|
||||||
|
export type ManagedPlaylistScope = 'local' | 'cloud'
|
||||||
|
|
||||||
|
export interface PlaylistInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
img: string
|
||||||
|
cover_mode?: 'auto' | 'custom' | string
|
||||||
|
author?: string
|
||||||
|
play_count?: string
|
||||||
|
visit_count?: number
|
||||||
|
is_public?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppPlaylist {
|
||||||
|
id: string
|
||||||
|
scope: PlaylistScope
|
||||||
|
source: string
|
||||||
|
kind?: 'playlist' | 'album'
|
||||||
|
info: PlaylistInfo
|
||||||
|
list: Song[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPlainSong = (song: Song): Song => {
|
||||||
|
const raw = toRaw(song) as Song & Record<string, any>
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ''),
|
||||||
|
hash: raw.hash ?? null,
|
||||||
|
picUrl: String(raw.picUrl ?? ''),
|
||||||
|
url: String(raw.url ?? ''),
|
||||||
|
name: String(raw.name ?? ''),
|
||||||
|
artist: String(raw.artist ?? ''),
|
||||||
|
duration: String(raw.duration ?? ''),
|
||||||
|
source: String(raw.source ?? ''),
|
||||||
|
lyric: typeof raw.lyric === 'string' ? raw.lyric : undefined,
|
||||||
|
quality: raw.quality,
|
||||||
|
albumId: raw.albumId ?? null,
|
||||||
|
albumName: raw.albumName ?? null,
|
||||||
|
artistIds: Array.isArray(raw.artistIds) ? raw.artistIds.map(String) : null,
|
||||||
|
type: raw.type,
|
||||||
|
types: raw.types && typeof raw.types === 'object' ? { ...raw.types } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaylistsStore = defineStore('playlists', () => {
|
||||||
|
const local = ref<AppPlaylist[]>([])
|
||||||
|
const cloud = ref<AppPlaylist[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const all = computed(() => [...local.value, ...cloud.value])
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.playlist.list()
|
||||||
|
local.value = result.local || []
|
||||||
|
cloud.value = result.cloud || []
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Playlist] refresh failed:', err)
|
||||||
|
ElMessage.error(err?.message || '歌单加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicList = async (search = '', sort = 'visit', page = 1, limit = 50) => {
|
||||||
|
return await window.electronAPI.playlist.publicList(search, sort, page, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = async (scope: PlaylistScope, id: string) => {
|
||||||
|
return await window.electronAPI.playlist.get(scope as ManagedPlaylistScope, id) as AppPlaylist
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = async (scope: ManagedPlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.create(scope, data) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success(scope === 'local' ? '本地歌单已创建' : '云端歌单已创建')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async (scope: ManagedPlaylistScope, id: string, info: Partial<PlaylistInfo>) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.update(scope, id, info) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success('歌单已更新')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (scope: ManagedPlaylistScope, id: string) => {
|
||||||
|
const result = await window.electronAPI.playlist.delete(scope, id)
|
||||||
|
await refresh()
|
||||||
|
if (result.success) ElMessage.success('歌单已删除')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSong = async (scope: ManagedPlaylistScope, id: string, song: Song, index = -1) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.addSong(scope, id, toPlainSong(song), index) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success('已添加到歌单')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSong = async (scope: ManagedPlaylistScope, id: string, index: number) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.removeSong(scope, id, index) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success('已从歌单移除')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportPlaylist = async (scope: ManagedPlaylistScope, id: string) => {
|
||||||
|
const result = await window.electronAPI.playlist.export(scope, id)
|
||||||
|
if (result?.success) ElMessage.success('歌单已导出')
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const importPlaylist = async () => {
|
||||||
|
const result = await window.electronAPI.playlist.import()
|
||||||
|
if (result?.success) {
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success('歌单已导入')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertScope = async (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.convertScope(scope, id, targetScope) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success(targetScope === 'cloud' ? '已转换为云端歌单' : '已转换为本地歌单')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToLocal = async (scope: ManagedPlaylistScope, id: string) => {
|
||||||
|
const playlist = await window.electronAPI.playlist.copyToLocal(scope, id) as AppPlaylist
|
||||||
|
await refresh()
|
||||||
|
ElMessage.success('已另存为本地歌单')
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
local,
|
||||||
|
cloud,
|
||||||
|
all,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
publicList,
|
||||||
|
get,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
addSong,
|
||||||
|
removeSong,
|
||||||
|
exportPlaylist,
|
||||||
|
importPlaylist,
|
||||||
|
convertScope,
|
||||||
|
copyToLocal,
|
||||||
|
}
|
||||||
|
})
|
||||||
43
src/renderer/src/stores/timeCalibrator.ts
Normal file
43
src/renderer/src/stores/timeCalibrator.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const API_BASE_URL = 'https://api.qz.shiqianjiang.cn/app'
|
||||||
|
const CALIBRATE_ROUNDS = 3
|
||||||
|
|
||||||
|
let offsetMs = 0
|
||||||
|
|
||||||
|
async function singleCalibrate(): Promise<{ offset: number; rtt: number } | null> {
|
||||||
|
const localBefore = Date.now()
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE_URL}/time`, { cache: 'no-store' })
|
||||||
|
const localAfter = Date.now()
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const data = await resp.json() as { timestamp: number }
|
||||||
|
const rtt = localAfter - localBefore
|
||||||
|
const offset = data.timestamp + Math.floor(rtt / 2) - localAfter
|
||||||
|
return { offset, rtt }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calibrateTime(): Promise<void> {
|
||||||
|
let bestOffset = 0
|
||||||
|
let minRtt = Infinity
|
||||||
|
|
||||||
|
for (let i = 0; i < CALIBRATE_ROUNDS; i++) {
|
||||||
|
const result = await singleCalibrate()
|
||||||
|
if (!result) continue
|
||||||
|
if (result.rtt < minRtt) {
|
||||||
|
minRtt = result.rtt
|
||||||
|
bestOffset = result.offset
|
||||||
|
}
|
||||||
|
console.debug(`[TimeCalibrator] Round ${i}: rtt=${result.rtt}ms, offset=${result.offset}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRtt < Infinity) {
|
||||||
|
offsetMs = bestOffset
|
||||||
|
console.log(`[TimeCalibrator] Calibrated: offset=${offsetMs}ms (best rtt=${minRtt}ms)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calibratedNow(): number {
|
||||||
|
return Date.now() + offsetMs
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ input[type='radio'] {
|
|||||||
|
|
||||||
input[type='radio']:checked {
|
input[type='radio']:checked {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
background-color: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='radio']:checked::after {
|
input[type='radio']:checked::after {
|
||||||
@@ -86,4 +86,58 @@ input[type='radio']:checked::after {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted);
|
background: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-overlay {
|
||||||
|
background-color: rgba(12, 12, 12, 0.18) !important;
|
||||||
|
backdrop-filter: blur(14px) saturate(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box,
|
||||||
|
.el-dialog,
|
||||||
|
.el-popover.el-popper {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent) !important;
|
||||||
|
border-radius: 22px !important;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 84%, transparent) !important;
|
||||||
|
box-shadow: var(--shadow-elevated) !important;
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
backdrop-filter: blur(24px) saturate(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box {
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__title,
|
||||||
|
.el-dialog__title {
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message-box__message,
|
||||||
|
.el-dialog__body {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 68%, transparent) !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 88%, transparent) !important;
|
||||||
|
box-shadow: var(--shadow-lg) !important;
|
||||||
|
backdrop-filter: blur(18px) saturate(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message .el-message__content {
|
||||||
|
color: var(--color-text-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
border-color: transparent !important;
|
||||||
|
background: var(--color-accent-gradient) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
--theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
--theme-transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
|
||||||
/* Dynamic accent color (set via JS) */
|
/* Dynamic accent color (set via JS) */
|
||||||
--color-accent: #ec4141;
|
--color-accent: #8289d3;
|
||||||
|
--color-accent-gradient: #8289d3;
|
||||||
|
--color-atmosphere-gradient:
|
||||||
|
linear-gradient(180deg,
|
||||||
|
rgba(176, 186, 235, 0.36) 0%,
|
||||||
|
rgba(177, 191, 233, 0.31) 18%,
|
||||||
|
rgba(179, 201, 223, 0.25) 38%,
|
||||||
|
rgba(193, 192, 211, 0.18) 58%,
|
||||||
|
rgba(223, 172, 185, 0.11) 78%,
|
||||||
|
transparent 100%);
|
||||||
--color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, white);
|
--color-accent-hover: color-mix(in srgb, var(--color-accent) 85%, white);
|
||||||
--color-accent-soft: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
--color-accent-soft: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
|
||||||
@@ -24,8 +33,8 @@
|
|||||||
--radius-2xl: 32px;
|
--radius-2xl: 32px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
--sidebar-width: 240px;
|
--sidebar-width: 284px;
|
||||||
--topbar-height: 64px;
|
--topbar-height: 72px;
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions */
|
||||||
--transition-fast: 0.15s ease;
|
--transition-fast: 0.15s ease;
|
||||||
@@ -87,4 +96,4 @@ body,
|
|||||||
.action-btn,
|
.action-btn,
|
||||||
.toggle-slider {
|
.toggle-slider {
|
||||||
transition: var(--theme-transition);
|
transition: var(--theme-transition);
|
||||||
}
|
}
|
||||||
|
|||||||
203
src/renderer/src/types/electron.d.ts
vendored
203
src/renderer/src/types/electron.d.ts
vendored
@@ -3,6 +3,7 @@ export interface IElectronAPI {
|
|||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
isMaximized: () => Promise<boolean>;
|
isMaximized: () => Promise<boolean>;
|
||||||
|
setTaskbarProgress: (progress: number, mode?: 'normal' | 'paused') => Promise<boolean>;
|
||||||
qzplayer: {
|
qzplayer: {
|
||||||
load: (url: string) => Promise<void>;
|
load: (url: string) => Promise<void>;
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
@@ -17,11 +18,62 @@ export interface IElectronAPI {
|
|||||||
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
|
||||||
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
|
search: (pluginId: string, query: string, page: number, limit: number) => Promise<any>;
|
||||||
getLyric: (pluginId: string, id: string) => Promise<any>;
|
getLyric: (pluginId: string, id: string) => Promise<any>;
|
||||||
|
getPlaylist: (pluginId: string, id: string, page?: number, limit?: number) => Promise<AppPlaylist>;
|
||||||
|
getAlbum: (pluginId: string, id: string, page?: number, limit?: number) => Promise<AppPlaylist>;
|
||||||
getAll: () => Promise<any[]>;
|
getAll: () => Promise<any[]>;
|
||||||
uninstall: (pluginId: string) => Promise<boolean>;
|
uninstall: (pluginId: string) => Promise<boolean>;
|
||||||
install: () => Promise<{ success: boolean; message: string }>;
|
install: () => Promise<{ success: boolean; message: string }>;
|
||||||
onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void;
|
onChanged: (callback: (change: { action: string; pluginId?: string }) => void) => () => void;
|
||||||
};
|
};
|
||||||
|
auth: {
|
||||||
|
getState: () => Promise<AuthState>;
|
||||||
|
getAccessToken: () => Promise<string>;
|
||||||
|
login: (forcePrompt?: boolean) => Promise<{ success: boolean; url: string }>;
|
||||||
|
qrCreate: () => Promise<QrLoginSession>;
|
||||||
|
qrPoll: (sessionId: string, pollToken: string) => Promise<QrLoginPollResult>;
|
||||||
|
qrCancel: (sessionId: string, pollToken: string) => Promise<{ status: string; message?: string }>;
|
||||||
|
refresh: () => Promise<AuthState>;
|
||||||
|
logout: () => Promise<AuthState>;
|
||||||
|
onChanged: (callback: (payload: { status: string; message?: string; state: AuthState }) => void) => () => void;
|
||||||
|
};
|
||||||
|
listenTogether: {
|
||||||
|
getWsUrl: (params: Record<string, string>) => Promise<string>;
|
||||||
|
};
|
||||||
|
heartbeat: {
|
||||||
|
sendPc: (duration: number, timestamp?: number) => Promise<any>;
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
getListenTime: (detail?: number, userId?: string) => Promise<ListenTimeStat>;
|
||||||
|
getListenRange: (start: string, end: string, userId?: string) => Promise<ListenTimeRange>;
|
||||||
|
getListenRank: (period?: ListenRankPeriod, limit?: number) => Promise<ListenRankResponse>;
|
||||||
|
};
|
||||||
|
playlist: {
|
||||||
|
list: () => Promise<{ local: AppPlaylist[]; cloud: AppPlaylist[]; items: AppPlaylist[] }>;
|
||||||
|
publicList: (search?: string, sort?: string, page?: number, limit?: number) => Promise<{ items: AppPlaylist[]; total: number; page: number; limit: number; sort: string }>;
|
||||||
|
get: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
|
||||||
|
create: (scope: ManagedPlaylistScope, data: { name: string; desc?: string; is_public?: boolean }) => Promise<AppPlaylist>;
|
||||||
|
update: (scope: ManagedPlaylistScope, id: string, info: Partial<PlaylistInfo>) => Promise<AppPlaylist>;
|
||||||
|
delete: (scope: ManagedPlaylistScope, id: string) => Promise<{ success: boolean }>;
|
||||||
|
addSong: (scope: ManagedPlaylistScope, id: string, song: any, index?: number) => Promise<AppPlaylist>;
|
||||||
|
removeSong: (scope: ManagedPlaylistScope, id: string, index: number) => Promise<AppPlaylist>;
|
||||||
|
export: (scope: ManagedPlaylistScope, id: string) => Promise<{ success: boolean; canceled?: boolean; path?: string }>;
|
||||||
|
import: () => Promise<{ success: boolean; canceled?: boolean; playlist?: AppPlaylist }>;
|
||||||
|
convertScope: (scope: ManagedPlaylistScope, id: string, targetScope: ManagedPlaylistScope) => Promise<AppPlaylist>;
|
||||||
|
copyToLocal: (scope: ManagedPlaylistScope, id: string) => Promise<AppPlaylist>;
|
||||||
|
};
|
||||||
|
image: {
|
||||||
|
selectAndUpload: () => Promise<{ success: boolean; canceled?: boolean; url?: string; message?: string }>;
|
||||||
|
};
|
||||||
|
privacy: {
|
||||||
|
getLibrary: () => Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }>;
|
||||||
|
setLibrary: (payload: { allow_public_library?: boolean; allow_public_profile?: boolean }) => Promise<{ status: string; allow_public_library: boolean; allow_public_profile: boolean }>;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
getProfile: (userId: string) => Promise<UserInfo>;
|
||||||
|
getPlaylists: (userId: string) => Promise<PlaylistInfo[]>;
|
||||||
|
getFavSongs: (userId: string) => Promise<any[]>;
|
||||||
|
updateProfile: (payload: Partial<UserInfo>) => Promise<UserInfo>;
|
||||||
|
};
|
||||||
// Cache Control
|
// Cache Control
|
||||||
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
getCacheInfo: () => Promise<{ path: string; size: string; persistCache: boolean }>;
|
||||||
setCachePersist: (persist: boolean) => Promise<void>;
|
setCachePersist: (persist: boolean) => Promise<void>;
|
||||||
@@ -29,10 +81,18 @@ export interface IElectronAPI {
|
|||||||
clearCache: () => Promise<void>;
|
clearCache: () => Promise<void>;
|
||||||
changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>;
|
changeCacheLocation: (newPath: string) => Promise<{ success: boolean; message: string; path?: string }>;
|
||||||
selectDirectory: () => Promise<string | null>;
|
selectDirectory: () => Promise<string | null>;
|
||||||
|
selectDirectories: () => Promise<string[]>;
|
||||||
|
localMusic: {
|
||||||
|
getLibrary: () => Promise<LocalMusicLibrary>;
|
||||||
|
scan: (roots: string[]) => Promise<LocalMusicLibrary>;
|
||||||
|
setRoots: (roots: string[]) => Promise<LocalMusicLibrary>;
|
||||||
|
remove: (id: string) => Promise<LocalMusicLibrary>;
|
||||||
|
clearMissing: () => Promise<LocalMusicLibrary>;
|
||||||
|
};
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>;
|
getAll: () => Promise<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string; playlistPagingMode: 'infinite' | 'pagination'; openPlayerOnSongClick: boolean }>;
|
||||||
set: (settings: Partial<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string }>) => Promise<any>;
|
set: (settings: Partial<{ persistCache: boolean; theme: 'dark' | 'light'; accentColor: string; playlistPagingMode: 'infinite' | 'pagination'; openPlayerOnSongClick: boolean }>) => Promise<any>;
|
||||||
getTheme: () => Promise<'dark' | 'light'>;
|
getTheme: () => Promise<'dark' | 'light'>;
|
||||||
setTheme: (theme: 'dark' | 'light') => Promise<void>;
|
setTheme: (theme: 'dark' | 'light') => Promise<void>;
|
||||||
getAccentColor: () => Promise<string>;
|
getAccentColor: () => Promise<string>;
|
||||||
@@ -45,3 +105,142 @@ declare global {
|
|||||||
electronAPI: IElectronAPI
|
electronAPI: IElectronAPI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
gender?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
intro?: string | null;
|
||||||
|
birthday?: string | null;
|
||||||
|
subscribing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
exp: number;
|
||||||
|
userInfo: UserInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrLoginSession {
|
||||||
|
status: string;
|
||||||
|
session_id: string;
|
||||||
|
poll_token: string;
|
||||||
|
qr_payload: string;
|
||||||
|
qr_data_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
expires_in: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QrLoginPollResult {
|
||||||
|
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'error' | string;
|
||||||
|
message?: string;
|
||||||
|
device_name?: string;
|
||||||
|
expires_at?: number;
|
||||||
|
state?: AuthState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaylistScope = 'local' | 'cloud' | 'plugin';
|
||||||
|
export type ManagedPlaylistScope = 'local' | 'cloud';
|
||||||
|
|
||||||
|
export interface PlaylistInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
img: string;
|
||||||
|
cover_mode?: 'auto' | 'custom' | string;
|
||||||
|
author?: string;
|
||||||
|
play_count?: string;
|
||||||
|
visit_count?: number;
|
||||||
|
is_public?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppPlaylist {
|
||||||
|
id: string;
|
||||||
|
scope: PlaylistScope;
|
||||||
|
source: string;
|
||||||
|
kind?: 'playlist' | 'album';
|
||||||
|
info: PlaylistInfo;
|
||||||
|
list: any[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalSong {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
source: 'local';
|
||||||
|
type: 'Local';
|
||||||
|
url: string;
|
||||||
|
picUrl: string;
|
||||||
|
lyric: string;
|
||||||
|
quality: string;
|
||||||
|
bitrate: number;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: number;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMusicLibrary {
|
||||||
|
roots: string[];
|
||||||
|
songs: LocalSong[];
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenTimePoint {
|
||||||
|
time?: string;
|
||||||
|
date?: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenTimeStat {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
msg?: string;
|
||||||
|
total?: number;
|
||||||
|
android_time?: number;
|
||||||
|
pc_time?: number;
|
||||||
|
chart_data?: {
|
||||||
|
daily?: ListenTimePoint[];
|
||||||
|
weekly?: ListenTimePoint[];
|
||||||
|
monthly?: ListenTimePoint[];
|
||||||
|
yearly?: ListenTimePoint[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenTimeRange {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
msg?: string;
|
||||||
|
data: Array<{ date: string; duration: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListenRankPeriod = 'week' | 'month' | 'year';
|
||||||
|
|
||||||
|
export interface ListenRankUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenRankItem {
|
||||||
|
rank: number;
|
||||||
|
duration: number;
|
||||||
|
user: ListenRankUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenRankResponse {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
period: ListenRankPeriod;
|
||||||
|
msg?: string;
|
||||||
|
data: ListenRankItem[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Song {
|
|||||||
artist: string;
|
artist: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
lyric?: string;
|
||||||
quality?: string; // default 'auto'
|
quality?: string; // default 'auto'
|
||||||
albumId?: string | null;
|
albumId?: string | null;
|
||||||
albumName?: string | null;
|
albumName?: string | null;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type LyricData = {
|
|||||||
yrc?: string
|
yrc?: string
|
||||||
qrc?: string
|
qrc?: string
|
||||||
lrc?: string
|
lrc?: string
|
||||||
|
plain?: string
|
||||||
lyric?: string
|
lyric?: string
|
||||||
translate?: string
|
translate?: string
|
||||||
translatedLyric?: string
|
translatedLyric?: string
|
||||||
@@ -136,7 +137,7 @@ function normalizeLyricData(input: unknown): LyricData | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const format = detectLyricFormat(raw)
|
const format = detectLyricFormat(raw)
|
||||||
return format ? { [format]: raw } : null
|
return format ? { [format]: raw } : { plain: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input || typeof input !== 'object') return null
|
if (!input || typeof input !== 'object') return null
|
||||||
@@ -160,6 +161,10 @@ function normalizeLyricData(input: unknown): LyricData | null {
|
|||||||
[format]: data.lyric,
|
[format]: data.lyric,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
plain: data.lyric,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
@@ -171,9 +176,33 @@ function parsePrimaryLyric(data: LyricData): LyricLine[] {
|
|||||||
if (typeof data.yrc === 'string') return parseYrc(data.yrc)
|
if (typeof data.yrc === 'string') return parseYrc(data.yrc)
|
||||||
if (typeof data.qrc === 'string') return parseQrc(data.qrc)
|
if (typeof data.qrc === 'string') return parseQrc(data.qrc)
|
||||||
if (typeof data.lrc === 'string') return parseLrc(data.lrc)
|
if (typeof data.lrc === 'string') return parseLrc(data.lrc)
|
||||||
|
if (typeof data.plain === 'string') return parsePlainLyric(data.plain)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePlainLyric(raw: string): LyricLine[] {
|
||||||
|
return raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((text, index) => {
|
||||||
|
const startTime = index * defaultLineDuration
|
||||||
|
return {
|
||||||
|
startTime,
|
||||||
|
endTime: startTime + defaultLineDuration,
|
||||||
|
words: [{
|
||||||
|
word: text,
|
||||||
|
startTime,
|
||||||
|
endTime: startTime + defaultLineDuration,
|
||||||
|
}],
|
||||||
|
translatedLyric: '',
|
||||||
|
romanLyric: '',
|
||||||
|
isBG: false,
|
||||||
|
isDuet: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function lineText(line: LyricLine): string {
|
function lineText(line: LyricLine): string {
|
||||||
return line.words.map((word) => word.word).join('').trim()
|
return line.words.map((word) => word.word).join('').trim()
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
365
src/renderer/src/views/ListenRank.vue
Normal file
365
src/renderer/src/views/ListenRank.vue
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
<template>
|
||||||
|
<div class="listen-rank-view">
|
||||||
|
<section class="rank-hero">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<Icon icon="lucide:trophy" />
|
||||||
|
<span>LISTEN RANK</span>
|
||||||
|
</div>
|
||||||
|
<h1>排行</h1>
|
||||||
|
<p>{{ authStore.isLoggedIn ? '看看本周、本月和本年谁听得最久。' : '登录后查看云端听歌时长排行榜。' }}</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="!authStore.isLoggedIn" class="primary-btn" @click="openLoginDialog">
|
||||||
|
<Icon icon="lucide:log-in" />
|
||||||
|
登录账号
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-if="authStore.isLoggedIn">
|
||||||
|
<div class="rank-tabs">
|
||||||
|
<button
|
||||||
|
v-for="option in rankOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:class="{ active: rankPeriod === option.value }"
|
||||||
|
@click="rankPeriod = option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rank-panel">
|
||||||
|
<div class="rank-panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ rankPeriodLabel }}听歌时长</h2>
|
||||||
|
<p>只展示前 200 名,从新版记录开始计入。</p>
|
||||||
|
</div>
|
||||||
|
<button class="refresh-btn" :disabled="loadingRank" @click="loadRank">
|
||||||
|
<Icon :icon="loadingRank ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loadingRank }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingRank" class="rank-loading">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
<span>加载排行榜...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="rankError" class="rank-empty">{{ rankError }}</div>
|
||||||
|
<div v-else-if="rankItems.length === 0" class="rank-empty">
|
||||||
|
这个周期还没有排行榜数据,更新后产生新的听歌记录就会出现。
|
||||||
|
</div>
|
||||||
|
<div v-else class="rank-list">
|
||||||
|
<button v-for="item in rankItems" :key="item.user.id" class="rank-row" @click="openUserProfile(item.user.id)">
|
||||||
|
<div class="rank-number" :class="`top-${Math.min(item.rank, 3)}`">{{ item.rank }}</div>
|
||||||
|
<img v-if="item.user.avatar" class="rank-avatar" :src="item.user.avatar" alt="" />
|
||||||
|
<div v-else class="rank-avatar fallback">{{ rankInitial(item.user.nickname || item.user.username) }}</div>
|
||||||
|
<div class="rank-user">
|
||||||
|
<strong>{{ item.user.nickname || item.user.username }}</strong>
|
||||||
|
<span>{{ item.user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<strong class="rank-duration">{{ formatTime(item.duration) }}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import type { ListenRankPeriod, ListenRankResponse } from '../types/electron'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
|
||||||
|
const rankPeriod = ref<ListenRankPeriod>('week')
|
||||||
|
const rankData = ref<ListenRankResponse | null>(null)
|
||||||
|
const loadingRank = ref(false)
|
||||||
|
const rankError = ref('')
|
||||||
|
const rankOptions: Array<{ label: string; value: ListenRankPeriod }> = [
|
||||||
|
{ label: '本周', value: 'week' },
|
||||||
|
{ label: '本月', value: 'month' },
|
||||||
|
{ label: '本年', value: 'year' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rankItems = computed(() => rankData.value?.data || [])
|
||||||
|
const rankPeriodLabel = computed(() => rankOptions.find((item) => item.value === rankPeriod.value)?.label || '本周')
|
||||||
|
const rankInitial = (name: string) => (name || 'Q').trim().slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000)
|
||||||
|
const hours = Math.floor(totalMinutes / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserProfile = (userId: string) => {
|
||||||
|
if (!userId) return
|
||||||
|
router.push({ name: 'UserProfile', params: { id: userId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRank = async () => {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
loadingRank.value = true
|
||||||
|
rankError.value = ''
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.stats.getListenRank(rankPeriod.value, 200)
|
||||||
|
if (result.status === 'error') {
|
||||||
|
rankError.value = result.msg || '排行榜暂时不可用'
|
||||||
|
rankData.value = null
|
||||||
|
} else {
|
||||||
|
rankData.value = result
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
rankError.value = err?.message || '排行榜暂时不可用'
|
||||||
|
rankData.value = null
|
||||||
|
} finally {
|
||||||
|
loadingRank.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([rankPeriod, () => authStore.isLoggedIn], loadRank, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.listen-rank-view {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 28px 32px 148px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-hero,
|
||||||
|
.rank-panel {
|
||||||
|
border-radius: 28px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-hero {
|
||||||
|
min-height: 170px;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--color-accent) 20%, transparent), transparent 38%),
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 58%),
|
||||||
|
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.rank-tabs,
|
||||||
|
.rank-panel-head,
|
||||||
|
.rank-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.refresh-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-tabs {
|
||||||
|
width: fit-content;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 18px 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-tabs button {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 72px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-tabs button.active {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel-head h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel-head p {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-row {
|
||||||
|
min-height: 58px;
|
||||||
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 56%, transparent);
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 800;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number.top-1 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #f7c86a, #f08b5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number.top-2 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #9fb1c8, #7486a6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-number.top-3 {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #d69a7a, #a96e57);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-avatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-avatar.fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-user {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-user strong,
|
||||||
|
.rank-user span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-user span,
|
||||||
|
.rank-empty,
|
||||||
|
.rank-loading {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-duration {
|
||||||
|
color: var(--color-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-empty,
|
||||||
|
.rank-loading {
|
||||||
|
min-height: 220px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.rank-hero {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
606
src/renderer/src/views/ListenStats.vue
Normal file
606
src/renderer/src/views/ListenStats.vue
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
<template>
|
||||||
|
<div class="listen-stats-view">
|
||||||
|
<section class="stats-hero">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<Icon icon="lucide:activity" />
|
||||||
|
<span>LISTEN FOOTPRINT</span>
|
||||||
|
</div>
|
||||||
|
<h1>听歌足迹</h1>
|
||||||
|
<p>{{ authStore.isLoggedIn ? '看看这一段时间音乐陪你走过了多久。' : '登录后同步查看云端听歌时长。' }}</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="!authStore.isLoggedIn" class="primary-btn" @click="openLoginDialog">
|
||||||
|
<Icon icon="lucide:log-in" />
|
||||||
|
登录账号
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-if="authStore.isLoggedIn">
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="{ active: activeTab === 'week' }" @click="activeTab = 'week'">周</button>
|
||||||
|
<button :class="{ active: activeTab === 'month' }" @click="activeTab = 'month'">月</button>
|
||||||
|
<button :class="{ active: activeTab === 'year' }" @click="activeTab = 'year'">年</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="stats-page" mode="out-in">
|
||||||
|
<section v-if="activeTab === 'week'" :key="`week-${weekOffset}`" class="stats-panel">
|
||||||
|
<div class="date-switcher">
|
||||||
|
<button :disabled="!canGoPrevWeek" @click="weekOffset--"><Icon icon="lucide:chevron-left" /></button>
|
||||||
|
<strong>{{ weekTitle }}</strong>
|
||||||
|
<button :disabled="weekOffset >= 0" @click="weekOffset++"><Icon icon="lucide:chevron-right" /></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingRange" class="loading-state"><Icon icon="lucide:loader-2" class="spin" />加载中</div>
|
||||||
|
<div v-else class="bar-chart">
|
||||||
|
<div v-for="item in weeklyBars" :key="item.label" class="bar-item">
|
||||||
|
<div class="bar-value">{{ formatShortTime(item.duration) }}</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill" :style="{ height: `${item.percent}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bar-label">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-line">
|
||||||
|
<span>本周合计</span>
|
||||||
|
<strong>{{ formatTime(weeklyTotal) }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="activeTab === 'month'" :key="`month-${monthOffset}`" class="stats-panel">
|
||||||
|
<div class="date-switcher">
|
||||||
|
<button :disabled="!canGoPrevMonth" @click="monthOffset--"><Icon icon="lucide:chevron-left" /></button>
|
||||||
|
<strong>{{ monthTitle }}</strong>
|
||||||
|
<button :disabled="monthOffset >= 0" @click="monthOffset++"><Icon icon="lucide:chevron-right" /></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loadingRange" class="loading-state"><Icon icon="lucide:loader-2" class="spin" />加载中</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="weekday-row">
|
||||||
|
<span v-for="day in weekdays" :key="day">{{ day }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div v-for="blank in monthLeadingBlanks" :key="`blank-${blank}`" class="day-cell blank"></div>
|
||||||
|
<button
|
||||||
|
v-for="day in monthDays"
|
||||||
|
:key="day.date"
|
||||||
|
class="day-cell"
|
||||||
|
:class="{ active: day.duration > 0, selected: selectedDate === day.date }"
|
||||||
|
:style="{ '--intensity': day.intensity }"
|
||||||
|
@click="selectedDate = selectedDate === day.date ? '' : day.date"
|
||||||
|
>
|
||||||
|
{{ day.day }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="summary-line">
|
||||||
|
<span>{{ selectedMonthText }}</span>
|
||||||
|
<strong>{{ formatTime(selectedMonthDuration) }}</strong>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else key="year" class="year-layout">
|
||||||
|
<div class="total-card">
|
||||||
|
<span>累计畅听</span>
|
||||||
|
<strong>{{ formatTime(totalStat?.total || 0) }}</strong>
|
||||||
|
<small>{{ totalStat?.status === 'error' ? totalStat.msg || '暂无统计数据' : '来自 Android 与 PC 的同步统计' }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="platform-grid">
|
||||||
|
<div class="platform-card">
|
||||||
|
<span>Android</span>
|
||||||
|
<strong>{{ formatTime(totalStat?.android_time || 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="platform-card">
|
||||||
|
<span>PC</span>
|
||||||
|
<strong>{{ formatTime(totalStat?.pc_time || 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section class="stats-panel compact">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>近五年</h2>
|
||||||
|
<button class="ghost-btn" :disabled="loadingTotal" @click="loadTotal">
|
||||||
|
<Icon :icon="loadingTotal ? 'lucide:loader-2' : 'lucide:refresh-cw'" :class="{ spin: loadingTotal }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="year-bars">
|
||||||
|
<div v-for="item in yearlyBars" :key="item.label" class="year-row">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<div class="year-track"><div :style="{ width: `${item.percent}%` }"></div></div>
|
||||||
|
<strong>{{ formatShortTime(item.duration) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import type { ListenTimeRange, ListenTimeStat } from '../types/electron'
|
||||||
|
|
||||||
|
type TabMode = 'week' | 'month' | 'year'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
|
||||||
|
const activeTab = ref<TabMode>('week')
|
||||||
|
const weekOffset = ref(0)
|
||||||
|
const monthOffset = ref(0)
|
||||||
|
const rangeData = ref<ListenTimeRange | null>(null)
|
||||||
|
const totalStat = ref<ListenTimeStat | null>(null)
|
||||||
|
const loadingRange = ref(false)
|
||||||
|
const loadingTotal = ref(false)
|
||||||
|
const selectedDate = ref('')
|
||||||
|
const minDate = new Date(2026, 2, 1)
|
||||||
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
|
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
const formatDate = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
|
||||||
|
const monthDate = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() + monthOffset.value, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekStart = computed(() => {
|
||||||
|
const base = addDays(new Date(), weekOffset.value * 7)
|
||||||
|
const day = base.getDay() || 7
|
||||||
|
return addDays(base, 1 - day)
|
||||||
|
})
|
||||||
|
const weekEnd = computed(() => addDays(weekStart.value, 6))
|
||||||
|
const weekTitle = computed(() => `${weekStart.value.getMonth() + 1}.${pad(weekStart.value.getDate())} - ${weekEnd.value.getMonth() + 1}.${pad(weekEnd.value.getDate())}`)
|
||||||
|
const monthTitle = computed(() => `${monthDate.value.getFullYear()} 年 ${monthDate.value.getMonth() + 1} 月`)
|
||||||
|
const canGoPrevWeek = computed(() => addDays(weekStart.value, -7) >= minDate)
|
||||||
|
const canGoPrevMonth = computed(() => new Date(monthDate.value.getFullYear(), monthDate.value.getMonth() - 1, 1) >= minDate)
|
||||||
|
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
const totalMinutes = Math.floor(ms / 60000)
|
||||||
|
const hours = Math.floor(totalMinutes / 60)
|
||||||
|
const minutes = totalMinutes % 60
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||||
|
}
|
||||||
|
const formatShortTime = (ms: number) => {
|
||||||
|
const minutes = Math.round(ms / 60000)
|
||||||
|
if (minutes <= 0) return '0m'
|
||||||
|
if (minutes < 60) return `${minutes}m`
|
||||||
|
return `${Math.round(minutes / 60)}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeMap = computed(() => new Map((rangeData.value?.data || []).map((item) => [item.date, item.duration])))
|
||||||
|
const weeklyTotal = computed(() => weeklyBars.value.reduce((sum, item) => sum + item.duration, 0))
|
||||||
|
const weeklyBars = computed(() => {
|
||||||
|
const values = weekdays.map((label, index) => ({
|
||||||
|
label,
|
||||||
|
duration: rangeMap.value.get(formatDate(addDays(weekStart.value, index))) || 0,
|
||||||
|
}))
|
||||||
|
const max = Math.max(1, ...values.map((item) => item.duration))
|
||||||
|
return values.map((item) => ({ ...item, percent: Math.max(4, (item.duration / max) * 100) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLeadingBlanks = computed(() => {
|
||||||
|
const day = monthDate.value.getDay() || 7
|
||||||
|
return Math.max(0, day - 1)
|
||||||
|
})
|
||||||
|
const monthDays = computed(() => {
|
||||||
|
const year = monthDate.value.getFullYear()
|
||||||
|
const month = monthDate.value.getMonth()
|
||||||
|
const count = new Date(year, month + 1, 0).getDate()
|
||||||
|
const durations = Array.from({ length: count }, (_, index) => {
|
||||||
|
const date = formatDate(new Date(year, month, index + 1))
|
||||||
|
return { date, day: index + 1, duration: rangeMap.value.get(date) || 0 }
|
||||||
|
})
|
||||||
|
const max = Math.max(1, ...durations.map((item) => item.duration))
|
||||||
|
return durations.map((item) => ({
|
||||||
|
...item,
|
||||||
|
intensity: item.duration > 0 ? String(Math.max(0.22, item.duration / max)) : '0',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const selectedMonthText = computed(() => selectedDate.value ? `${selectedDate.value} 听歌` : '本月合计')
|
||||||
|
const selectedMonthDuration = computed(() => {
|
||||||
|
if (selectedDate.value) return rangeMap.value.get(selectedDate.value) || 0
|
||||||
|
return monthDays.value.reduce((sum, day) => sum + day.duration, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearlyBars = computed(() => {
|
||||||
|
const items = totalStat.value?.chart_data?.yearly || []
|
||||||
|
const max = Math.max(1, ...items.map((item) => item.duration))
|
||||||
|
return items.map((item) => ({
|
||||||
|
label: item.time || '-',
|
||||||
|
duration: item.duration,
|
||||||
|
percent: Math.max(4, (item.duration / max) * 100),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
const loadRange = async () => {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
loadingRange.value = true
|
||||||
|
try {
|
||||||
|
if (activeTab.value === 'week') {
|
||||||
|
rangeData.value = await window.electronAPI.stats.getListenRange(formatDate(weekStart.value), formatDate(weekEnd.value))
|
||||||
|
} else {
|
||||||
|
const start = monthDate.value
|
||||||
|
const end = new Date(start.getFullYear(), start.getMonth() + 1, 0)
|
||||||
|
rangeData.value = await window.electronAPI.stats.getListenRange(formatDate(start), formatDate(end))
|
||||||
|
selectedDate.value = ''
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingRange.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTotal = async () => {
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
loadingTotal.value = true
|
||||||
|
try {
|
||||||
|
totalStat.value = await window.electronAPI.stats.getListenTime(1)
|
||||||
|
} finally {
|
||||||
|
loadingTotal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([activeTab, weekOffset, monthOffset, () => authStore.isLoggedIn], () => {
|
||||||
|
if (activeTab.value === 'year') loadTotal()
|
||||||
|
else loadRange()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(loadTotal)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.listen-stats-view {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 28px 32px 148px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-hero,
|
||||||
|
.stats-panel,
|
||||||
|
.total-card,
|
||||||
|
.platform-card {
|
||||||
|
border-radius: 28px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-hero {
|
||||||
|
min-height: 170px;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 56%),
|
||||||
|
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.tabs,
|
||||||
|
.date-switcher,
|
||||||
|
.summary-line,
|
||||||
|
.panel-heading,
|
||||||
|
.year-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.ghost-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
width: fit-content;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 18px 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 72px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-switcher {
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-switcher button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 0;
|
||||||
|
transition: background-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-switcher button svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-switcher button:not(:disabled):hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart {
|
||||||
|
height: 260px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 28px 1fr 24px;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-value,
|
||||||
|
.bar-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 4px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
transition: height 260ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-line {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-line span,
|
||||||
|
.platform-card span,
|
||||||
|
.total-card span,
|
||||||
|
.total-card small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-line strong {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row,
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-row {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 76%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: background-color 180ms ease, color 180ms ease, outline-color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.active {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) calc(var(--intensity) * 46%), var(--color-bg-primary));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.selected {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.blank {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.8fr) minmax(320px, 1.2fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card,
|
||||||
|
.platform-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card {
|
||||||
|
min-height: 230px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 22%, transparent), transparent 62%),
|
||||||
|
color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card strong {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card small {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-panel.compact {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-row {
|
||||||
|
grid-template-columns: 58px minmax(0, 1fr) 74px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-row span,
|
||||||
|
.year-row strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-track div {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
transition: width 260ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-page-enter-active,
|
||||||
|
.stats-page-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-page-enter-from,
|
||||||
|
.stats-page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.stats-hero,
|
||||||
|
.year-layout {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.platform-grid,
|
||||||
|
.stats-panel.compact {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
579
src/renderer/src/views/ListenTogether.vue
Normal file
579
src/renderer/src/views/ListenTogether.vue
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
<template>
|
||||||
|
<div class="together-page">
|
||||||
|
<section class="room-hero" :style="{ '--room-hero-bg': `url('${roomHeroBg}')` }">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<div class="eyebrow">
|
||||||
|
<Icon icon="lucide:radio-tower" />
|
||||||
|
<span>{{ together.connected ? '房间在线' : '一起听' }}</span>
|
||||||
|
</div>
|
||||||
|
<h1>{{ together.connected ? `房间 ${together.roomId}` : '同步播放室' }}</h1>
|
||||||
|
<p>{{ statusText }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-actions">
|
||||||
|
<button v-if="!authStore.isLoggedIn" class="primary-action" @click="openLoginDialog">
|
||||||
|
<Icon icon="lucide:log-in" />
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<template v-else-if="!together.connected">
|
||||||
|
<button class="primary-action" :disabled="together.connecting" @click="createRoom">
|
||||||
|
<Icon :icon="together.connecting ? 'lucide:loader-2' : 'lucide:plus'" :class="{ spin: together.connecting }" />
|
||||||
|
创建房间
|
||||||
|
</button>
|
||||||
|
<button class="soft-action" :disabled="together.connecting || !normalizedJoinCode" @click="joinRoom">
|
||||||
|
<Icon icon="lucide:door-open" />
|
||||||
|
加入
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="soft-action" :disabled="!together.canControl" @click="together.sendCurrentSnapshot()">
|
||||||
|
<Icon icon="lucide:refresh-cw" />
|
||||||
|
同步
|
||||||
|
</button>
|
||||||
|
<button class="danger-action" @click="leaveRoom">
|
||||||
|
<Icon icon="lucide:log-out" />
|
||||||
|
{{ together.isHost ? '关闭房间' : '离开' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="room-grid">
|
||||||
|
<section class="control-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>房间</h2>
|
||||||
|
<span>{{ permissionText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mode-toggle" :class="{ disabled: together.connected }">
|
||||||
|
<button :class="{ active: roomMode === 'multi' }" :disabled="together.connected" @click="roomMode = 'multi'">
|
||||||
|
多人
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: roomMode === 'dual' }" :disabled="together.connected" @click="roomMode = 'dual'">
|
||||||
|
双人
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!together.connected" class="join-box">
|
||||||
|
<label>
|
||||||
|
<span>房间码</span>
|
||||||
|
<input v-model="joinText" placeholder="输入或粘贴分享文本" />
|
||||||
|
</label>
|
||||||
|
<div class="hint-row">
|
||||||
|
<span>{{ normalizedJoinCode || '等待房间码' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="room-code">
|
||||||
|
<span>{{ together.roomId }}</span>
|
||||||
|
<button @click="copyRoomCode">
|
||||||
|
<Icon icon="lucide:copy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="now-row">
|
||||||
|
<div class="song-cover">
|
||||||
|
<img v-if="player.currentSong?.picUrl" :src="player.currentSong.picUrl" alt="" />
|
||||||
|
<Icon v-else icon="lucide:music-2" />
|
||||||
|
</div>
|
||||||
|
<div class="song-copy">
|
||||||
|
<span>{{ player.currentSong?.name || '未播放' }}</span>
|
||||||
|
<small>{{ player.currentSong?.artist || 'QZ Music' }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="play-state" :class="{ playing: player.isPlaying }">
|
||||||
|
{{ player.isPlaying ? '播放中' : '已暂停' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="members-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>成员</h2>
|
||||||
|
<span>{{ together.userList.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="member-list">
|
||||||
|
<div v-for="uid in together.userList" :key="uid" class="member-item">
|
||||||
|
<div class="avatar">{{ uid.slice(0, 1).toUpperCase() }}</div>
|
||||||
|
<div class="member-copy">
|
||||||
|
<span>{{ uid === authStore.state.userInfo?.id ? '我' : uid }}</span>
|
||||||
|
<small>{{ permissionLabel(together.allPermissions[uid] ?? 0) }}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="together.isHost && uid !== authStore.state.userInfo?.id"
|
||||||
|
class="permission-btn"
|
||||||
|
@click="togglePermission(uid)"
|
||||||
|
>
|
||||||
|
{{ (together.allPermissions[uid] ?? 0) >= 1 ? '旁听' : '控制' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="together.userList.length === 0" class="empty-state">暂无成员</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="queue-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h2>播放队列</h2>
|
||||||
|
<span>{{ player.playlist.length }} 首</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-list">
|
||||||
|
<div
|
||||||
|
v-for="(song, index) in player.playlist"
|
||||||
|
:key="`${song.source}:${song.id}:${index}`"
|
||||||
|
class="queue-item"
|
||||||
|
:class="{ active: index === player.currentIndex }"
|
||||||
|
>
|
||||||
|
<span class="queue-index">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||||
|
<img v-if="song.picUrl" :src="song.picUrl" alt="" />
|
||||||
|
<div v-else class="queue-placeholder"><Icon icon="lucide:music" /></div>
|
||||||
|
<div class="queue-copy">
|
||||||
|
<span>{{ song.name }}</span>
|
||||||
|
<small>{{ song.artist }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="player.playlist.length === 0" class="empty-state">队列为空</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import roomHeroBg from '../assets/long_width_bg.png'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useListenTogetherStore } from '../stores/listenTogether'
|
||||||
|
import { usePlayerStore } from '../stores/player'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const together = useListenTogetherStore()
|
||||||
|
const player = usePlayerStore()
|
||||||
|
const openLoginDialog = inject<() => void>('openLoginDialog', () => authStore.login(false))
|
||||||
|
|
||||||
|
const roomMode = ref<'dual' | 'multi'>('multi')
|
||||||
|
const joinText = ref('')
|
||||||
|
|
||||||
|
const normalizedJoinCode = computed(() => {
|
||||||
|
const text = joinText.value.trim()
|
||||||
|
const shared = text.match(/#([a-z0-9]{6})#/i)
|
||||||
|
if (shared) return shared[1].toLowerCase()
|
||||||
|
const plain = text.match(/[a-z0-9]{6}/i)
|
||||||
|
return plain ? plain[0].toLowerCase() : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (!authStore.isLoggedIn) return '登录后可创建或加入房间'
|
||||||
|
if (together.connecting) return '正在连接房间'
|
||||||
|
if (together.connected) return together.canControl ? '当前设备可控制播放' : '当前设备正在跟随播放'
|
||||||
|
return '创建房间或加入已有房间'
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissionText = computed(() => permissionLabel(together.permissionLevel))
|
||||||
|
|
||||||
|
const permissionLabel = (level: number) => {
|
||||||
|
if (level >= 2) return '房主'
|
||||||
|
if (level >= 1) return '可控制'
|
||||||
|
return '旁听'
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRoom = () => {
|
||||||
|
if (!authStore.isLoggedIn) return openLoginDialog()
|
||||||
|
together.createRoom(roomMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinRoom = () => {
|
||||||
|
if (!authStore.isLoggedIn) return openLoginDialog()
|
||||||
|
if (!normalizedJoinCode.value) return
|
||||||
|
together.joinRoom(normalizedJoinCode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveRoom = () => {
|
||||||
|
together.disconnect(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRoomCode = async () => {
|
||||||
|
if (!together.roomId) return
|
||||||
|
const text = `加入一起听歌#${together.roomId}#`
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('房间码已复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePermission = (uid: string) => {
|
||||||
|
const current = together.allPermissions[uid] ?? 0
|
||||||
|
together.changePermission(uid, current >= 1 ? 0 : 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.together-page {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 28px 32px 132px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-hero {
|
||||||
|
min-height: 196px;
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 82%, transparent);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -18px;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--room-hero-bg) center / cover no-repeat;
|
||||||
|
filter: blur(12px) saturate(1.08);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(18, 20, 32, 0.72) 0%, rgba(18, 20, 32, 0.38) 52%, rgba(18, 20, 32, 0.18) 100%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.10) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.hero-actions,
|
||||||
|
.panel-heading,
|
||||||
|
.now-row,
|
||||||
|
.member-item,
|
||||||
|
.queue-item,
|
||||||
|
.room-code,
|
||||||
|
.hint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1 {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action,
|
||||||
|
.soft-action,
|
||||||
|
.danger-action,
|
||||||
|
.permission-btn,
|
||||||
|
.room-code button {
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action {
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-action,
|
||||||
|
.permission-btn,
|
||||||
|
.room-code button {
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 72%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-action {
|
||||||
|
background: rgba(255, 85, 85, 0.14);
|
||||||
|
color: #ff7070;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1.1fr) minmax(280px, 0.9fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel,
|
||||||
|
.members-panel,
|
||||||
|
.queue-panel {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 22px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 68%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading span,
|
||||||
|
.member-copy small,
|
||||||
|
.queue-copy small,
|
||||||
|
.song-copy small,
|
||||||
|
.hint-row {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle button {
|
||||||
|
min-width: 62px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle button.active {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-box label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-box label span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-box input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 82%, transparent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-code {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
height: 50px;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-code span {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-code button {
|
||||||
|
width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-row {
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover,
|
||||||
|
.avatar,
|
||||||
|
.queue-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-cover img,
|
||||||
|
.queue-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-copy,
|
||||||
|
.member-copy,
|
||||||
|
.queue-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-copy span,
|
||||||
|
.member-copy span,
|
||||||
|
.queue-copy span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-state {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-state.playing {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list,
|
||||||
|
.queue-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 62px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 66%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item.active {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 13%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-index {
|
||||||
|
width: 24px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item img,
|
||||||
|
.queue-placeholder {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
min-height: 72px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.room-hero,
|
||||||
|
.room-grid {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members-panel,
|
||||||
|
.queue-panel {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,54 +1,683 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="view-container local-view">
|
<div class="local-view">
|
||||||
<h1 class="view-title">Local Files</h1>
|
<section class="local-hero">
|
||||||
<div class="empty-state">
|
<div>
|
||||||
<div class="icon-box">
|
<div class="eyebrow">
|
||||||
<Icon icon="lucide:music" width="48" height="48" />
|
<Icon icon="lucide:hard-drive" />
|
||||||
|
<span>LOCAL MUSIC</span>
|
||||||
|
</div>
|
||||||
|
<h1>本地音乐</h1>
|
||||||
|
<p>扫描电脑里的音频文件,按艺术家、专辑和修改时间整理播放。</p>
|
||||||
</div>
|
</div>
|
||||||
<p>No local files scanned yet.</p>
|
<div class="hero-actions">
|
||||||
<button class="action-btn">Scan Folder</button>
|
<button class="soft-btn" :disabled="scanning" @click="pickFolders">
|
||||||
</div>
|
<Icon icon="lucide:folder-plus" />
|
||||||
|
添加文件夹
|
||||||
|
</button>
|
||||||
|
<button class="primary-btn" :disabled="scanning || roots.length === 0" @click="scanRoots()">
|
||||||
|
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:scan-line'" :class="{ spin: scanning }" />
|
||||||
|
{{ scanning ? '扫描中' : '重新扫描' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stats-row">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>{{ songs.length }}</span>
|
||||||
|
<small>歌曲</small>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>{{ artistCount }}</span>
|
||||||
|
<small>艺术家</small>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span>{{ albumCount }}</span>
|
||||||
|
<small>专辑</small>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item wide" :title="roots.join('\n')">
|
||||||
|
<span>{{ roots.length || 0 }}</span>
|
||||||
|
<small>{{ roots.length ? roots[0] : '未选择目录' }}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="root-list" v-if="roots.length > 0">
|
||||||
|
<div class="root-title">扫描目录</div>
|
||||||
|
<button
|
||||||
|
v-for="root in roots"
|
||||||
|
:key="root"
|
||||||
|
class="root-chip"
|
||||||
|
:title="root"
|
||||||
|
:disabled="scanning"
|
||||||
|
@click="removeRoot(root)"
|
||||||
|
>
|
||||||
|
<span>{{ root }}</span>
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<Icon icon="lucide:search" />
|
||||||
|
<input v-model="query" placeholder="搜索歌曲、艺术家、专辑" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segmented">
|
||||||
|
<button :class="{ active: groupBy === 'all' }" @click="groupBy = 'all'">全部</button>
|
||||||
|
<button :class="{ active: groupBy === 'artist' }" @click="groupBy = 'artist'">艺术家</button>
|
||||||
|
<button :class="{ active: groupBy === 'album' }" @click="groupBy = 'album'">专辑</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select v-model="sortMode" class="sort-select">
|
||||||
|
<option value="az">A-Z 正序</option>
|
||||||
|
<option value="za">Z-A 倒序</option>
|
||||||
|
<option value="modified-desc">修改日期 新到旧</option>
|
||||||
|
<option value="modified-asc">修改日期 旧到新</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="scanning" class="state-panel">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
<span>正在读取音频标签...</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="songs.length === 0" class="state-panel">
|
||||||
|
<Icon icon="lucide:music-2" />
|
||||||
|
<span>还没有本地歌曲</span>
|
||||||
|
<button class="primary-btn compact" @click="pickFolders">扫描文件夹</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="local-content">
|
||||||
|
<template v-if="groupBy === 'all'">
|
||||||
|
<div class="list-header">
|
||||||
|
<span>#</span>
|
||||||
|
<span></span>
|
||||||
|
<span>标题</span>
|
||||||
|
<span>专辑</span>
|
||||||
|
<span>时长</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<TransitionGroup name="list-shift" tag="div" class="song-list">
|
||||||
|
<SongTile
|
||||||
|
v-for="(song, index) in pagedSongs"
|
||||||
|
:key="song.id"
|
||||||
|
:song="song"
|
||||||
|
:display-index="pageStart + index + 1"
|
||||||
|
removable
|
||||||
|
reserve-action
|
||||||
|
@play="playSong(pageStart + index)"
|
||||||
|
@remove="removeSong(song.id)"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<TransitionGroup name="list-shift" tag="div" class="group-transition-list">
|
||||||
|
<div v-for="group in pagedGroups" :key="group.name" class="group-block">
|
||||||
|
<button class="group-header" @click="toggleGroup(group.name)">
|
||||||
|
<div>
|
||||||
|
<strong>{{ group.name }}</strong>
|
||||||
|
<span>{{ group.songs.length }} 首</span>
|
||||||
|
</div>
|
||||||
|
<Icon :icon="collapsedGroups.has(group.name) ? 'lucide:chevron-right' : 'lucide:chevron-down'" />
|
||||||
|
</button>
|
||||||
|
<div v-if="!collapsedGroups.has(group.name)" class="group-list">
|
||||||
|
<SongTile
|
||||||
|
v-for="song in group.songs"
|
||||||
|
:key="song.id"
|
||||||
|
:song="song"
|
||||||
|
:display-index="songGlobalIndex(song)"
|
||||||
|
removable
|
||||||
|
reserve-action
|
||||||
|
@play="playSong(songGlobalIndex(song) - 1)"
|
||||||
|
@remove="removeSong(song.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="pagination" v-if="totalPages > 1">
|
||||||
|
<button class="page-btn" :disabled="currentPage <= 1" @click="currentPage--">
|
||||||
|
<Icon icon="lucide:chevron-left" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
class="page-btn"
|
||||||
|
:class="{ active: page === currentPage }"
|
||||||
|
@click="currentPage = page"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button class="page-btn" :disabled="currentPage >= totalPages" @click="currentPage++">
|
||||||
|
<Icon icon="lucide:chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import SongTile from '../components/SongTile.vue'
|
||||||
|
import { usePlayerStore } from '../stores/player'
|
||||||
|
import type { Song } from '../types/song'
|
||||||
|
|
||||||
|
interface LocalSong extends Song {
|
||||||
|
path: string
|
||||||
|
albumName: string
|
||||||
|
durationSeconds: number
|
||||||
|
quality: string
|
||||||
|
bitrate: number
|
||||||
|
sampleRate: number
|
||||||
|
channels: number
|
||||||
|
size: number
|
||||||
|
modifiedAt: number
|
||||||
|
addedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupMode = 'all' | 'artist' | 'album'
|
||||||
|
type SortMode = 'az' | 'za' | 'modified-desc' | 'modified-asc'
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const songs = ref<LocalSong[]>([])
|
||||||
|
const roots = ref<string[]>([])
|
||||||
|
const scanning = ref(false)
|
||||||
|
const query = ref('')
|
||||||
|
const groupBy = ref<GroupMode>('all')
|
||||||
|
const sortMode = ref<SortMode>('az')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 40
|
||||||
|
const collapsedGroups = ref(new Set<string>())
|
||||||
|
|
||||||
|
const toPlainRoots = (value: unknown): string[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.map((item) => String(item)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinyinCollator = new Intl.Collator('zh-Hans-CN-u-co-pinyin', {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base',
|
||||||
|
})
|
||||||
|
|
||||||
|
const compareByPinyin = (left = '', right = '') => pinyinCollator.compare(left, right)
|
||||||
|
|
||||||
|
const loadLibrary = async () => {
|
||||||
|
const library = await window.electronAPI.localMusic.getLibrary()
|
||||||
|
roots.value = library.roots || []
|
||||||
|
songs.value = (library.songs || []) as LocalSong[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFolders = async () => {
|
||||||
|
const selected = await window.electronAPI.selectDirectories()
|
||||||
|
if (selected.length === 0) return
|
||||||
|
const library = await window.electronAPI.localMusic.setRoots(Array.from(new Set([...toPlainRoots(roots.value), ...toPlainRoots(selected)])))
|
||||||
|
roots.value = library.roots || []
|
||||||
|
ElMessage.success('已添加到扫描目录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRoot = async (root: string) => {
|
||||||
|
const library = await window.electronAPI.localMusic.setRoots(toPlainRoots(roots.value).filter((item) => item !== root))
|
||||||
|
roots.value = library.roots || []
|
||||||
|
songs.value = (library.songs || []) as LocalSong[]
|
||||||
|
ElMessage.success('已从扫描目录移除')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanRoots = async (nextRoots: unknown = roots.value) => {
|
||||||
|
scanning.value = true
|
||||||
|
try {
|
||||||
|
const library = await window.electronAPI.localMusic.scan(toPlainRoots(nextRoots))
|
||||||
|
roots.value = library.roots || []
|
||||||
|
songs.value = (library.songs || []) as LocalSong[]
|
||||||
|
currentPage.value = 1
|
||||||
|
ElMessage.success(`扫描完成:${songs.value.length} 首歌曲`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[LocalMusic] scan failed:', error)
|
||||||
|
ElMessage.error(error?.message || '扫描失败')
|
||||||
|
} finally {
|
||||||
|
scanning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
|
||||||
|
const filteredSongs = computed(() => {
|
||||||
|
const q = normalizedQuery.value
|
||||||
|
const list = q
|
||||||
|
? songs.value.filter((song) => (
|
||||||
|
song.name.toLowerCase().includes(q) ||
|
||||||
|
song.artist.toLowerCase().includes(q) ||
|
||||||
|
(song.albumName || '').toLowerCase().includes(q)
|
||||||
|
))
|
||||||
|
: songs.value.slice()
|
||||||
|
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
if (sortMode.value === 'modified-desc') return b.modifiedAt - a.modifiedAt
|
||||||
|
if (sortMode.value === 'modified-asc') return a.modifiedAt - b.modifiedAt
|
||||||
|
const result = compareByPinyin(a.name, b.name)
|
||||||
|
return sortMode.value === 'za' ? -result : result
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const artistCount = computed(() => new Set(songs.value.map((song) => song.artist)).size)
|
||||||
|
const albumCount = computed(() => new Set(songs.value.map((song) => song.albumName)).size)
|
||||||
|
const totalItems = computed(() => groupBy.value === 'all' ? filteredSongs.value.length : groupedSongs.value.length)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize)))
|
||||||
|
const pageStart = computed(() => (currentPage.value - 1) * pageSize)
|
||||||
|
const pagedSongs = computed(() => filteredSongs.value.slice(pageStart.value, pageStart.value + pageSize))
|
||||||
|
|
||||||
|
const groupedSongs = computed(() => {
|
||||||
|
const map = new Map<string, LocalSong[]>()
|
||||||
|
for (const song of filteredSongs.value) {
|
||||||
|
const key = groupBy.value === 'artist' ? song.artist : song.albumName
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(song)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([name, groupSongs]) => ({ name, songs: groupSongs }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const result = compareByPinyin(a.name, b.name)
|
||||||
|
return sortMode.value === 'za' ? -result : result
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagedGroups = computed(() => groupedSongs.value.slice(pageStart.value, pageStart.value + pageSize))
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const delta = 2
|
||||||
|
const start = Math.max(1, Math.min(currentPage.value - delta, totalPages.value - delta * 2))
|
||||||
|
const end = Math.min(totalPages.value, Math.max(currentPage.value + delta, 1 + delta * 2))
|
||||||
|
const pages: number[] = []
|
||||||
|
for (let page = start; page <= end; page++) pages.push(page)
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
|
||||||
|
const playSong = (globalIndex: number) => {
|
||||||
|
const song = filteredSongs.value[globalIndex]
|
||||||
|
if (song) playerStore.playFromList(song, filteredSongs.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSong = async (id: string) => {
|
||||||
|
const library = await window.electronAPI.localMusic.remove(id)
|
||||||
|
songs.value = (library.songs || []) as LocalSong[]
|
||||||
|
ElMessage.success('已从本地列表移除')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGroup = (name: string) => {
|
||||||
|
const next = new Set(collapsedGroups.value)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
collapsedGroups.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const songGlobalIndex = (song: LocalSong) => filteredSongs.value.findIndex((item) => item.id === song.id) + 1
|
||||||
|
|
||||||
|
watch([query, groupBy, sortMode], () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(totalPages, (pages) => {
|
||||||
|
if (currentPage.value > pages) currentPage.value = pages
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadLibrary)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-title {
|
.local-view {
|
||||||
font-size: 2rem;
|
min-height: 100%;
|
||||||
font-weight: 700;
|
padding: 28px 32px 148px;
|
||||||
margin-bottom: 24px;
|
box-sizing: border-box;
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.local-hero {
|
||||||
height: 400px;
|
min-height: 190px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 30px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, color-mix(in srgb, var(--color-accent) 13%, transparent), transparent 52%),
|
||||||
|
color-mix(in srgb, var(--color-bg-secondary) 76%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(90% 90% at 0% 0%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 58%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.hero-actions,
|
||||||
|
.toolbar,
|
||||||
|
.search-box,
|
||||||
|
.stats-row,
|
||||||
|
.pagination,
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn,
|
||||||
|
.soft-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn.compact {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-btn {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(120px, 0.18fr)) minmax(240px, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-title {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-chip {
|
||||||
|
max-width: min(360px, 100%);
|
||||||
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-chip:hover:not(:disabled) {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-chip span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item span {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 22px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
height: 42px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-box {
|
.search-box input {
|
||||||
background-color: var(--color-bg-secondary);
|
flex: 1;
|
||||||
padding: 24px;
|
min-width: 0;
|
||||||
border-radius: 50%;
|
outline: none;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.segmented {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 86%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel {
|
||||||
|
min-height: 300px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel svg {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-content {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list,
|
||||||
|
.group-transition-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-shift-move,
|
||||||
|
.list-shift-enter-active,
|
||||||
|
.list-shift-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
transform 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-shift-enter-from,
|
||||||
|
.list-shift-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-shift-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 40px minmax(0, 4fr) minmax(100px, 3fr) 60px 42px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-block {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header strong,
|
||||||
|
.group-header span {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-list {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
gap: 8px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
background-color: var(--color-accent);
|
|
||||||
color: var(--color-bg-primary);
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-weight: 600;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.page-btn {
|
||||||
opacity: 0.9;
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled),
|
||||||
|
.page-btn.active {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.local-hero {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.list-shift-move,
|
||||||
|
.list-shift-enter-active,
|
||||||
|
.list-shift-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
393
src/renderer/src/views/PlaylistSquare.vue
Normal file
393
src/renderer/src/views/PlaylistSquare.vue
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<template>
|
||||||
|
<div class="playlist-square-view">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<section class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h1>歌单广场</h1>
|
||||||
|
<p>浏览其他用户公开的歌单</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-controls">
|
||||||
|
<label class="search-box">
|
||||||
|
<Icon icon="lucide:search" />
|
||||||
|
<input v-model="query" placeholder="搜索歌单、简介或作者" />
|
||||||
|
</label>
|
||||||
|
<div class="sort-tabs">
|
||||||
|
<button :class="{ active: sort === 'visit' }" @click="setSort('visit')">
|
||||||
|
<Icon icon="lucide:trending-up" />
|
||||||
|
访问量
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: sort === 'total' }" @click="setSort('total')">
|
||||||
|
<Icon icon="lucide:list-music" />
|
||||||
|
歌曲数
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: sort === 'name' }" @click="setSort('name')">
|
||||||
|
<Icon icon="lucide:arrow-down-a-z" />
|
||||||
|
名称
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="result-section">
|
||||||
|
<div class="section-meta">
|
||||||
|
<span>{{ loading ? '加载中' : `${total} 个公开歌单` }}</span>
|
||||||
|
<span>按{{ sortLabel }}排序 · 第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="playlist-grid">
|
||||||
|
<div v-for="i in pageSize" :key="i" class="playlist-card skeleton"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="playlists.length === 0" class="empty-state">
|
||||||
|
<Icon icon="lucide:library" />
|
||||||
|
<span>暂无匹配的公开歌单</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="playlist-grid">
|
||||||
|
<button
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-card"
|
||||||
|
@click="openPlaylist(playlist.id)"
|
||||||
|
>
|
||||||
|
<div class="cover">
|
||||||
|
<img v-if="playlist.info.img" :src="playlist.info.img" alt="" />
|
||||||
|
<Icon v-else icon="lucide:cloud" />
|
||||||
|
</div>
|
||||||
|
<div class="playlist-name">{{ playlist.info.name || '云歌单' }}</div>
|
||||||
|
<div class="playlist-desc">{{ playlist.info.desc || '没有简介' }}</div>
|
||||||
|
<div class="playlist-meta">
|
||||||
|
<span>
|
||||||
|
<Icon icon="lucide:eye" />
|
||||||
|
{{ Number(playlist.info.visit_count || playlist.info.play_count || 0) || 0 }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Icon icon="lucide:music" />
|
||||||
|
{{ playlist.total || 0 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && totalPages > 1" class="pagination">
|
||||||
|
<button class="page-btn" :disabled="page <= 1" @click="changePage(page - 1)">
|
||||||
|
<Icon icon="lucide:chevron-left" />
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button class="page-btn" :disabled="page >= totalPages" @click="changePage(page + 1)">
|
||||||
|
下一页
|
||||||
|
<Icon icon="lucide:chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { usePlaylistsStore, type AppPlaylist } from '../stores/playlists'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const playlistStore = usePlaylistsStore()
|
||||||
|
const query = ref('')
|
||||||
|
const sort = ref<'visit' | 'total' | 'name'>('visit')
|
||||||
|
const playlists = ref<AppPlaylist[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 12
|
||||||
|
const loading = ref(false)
|
||||||
|
let timer: number | null = null
|
||||||
|
let requestId = 0
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
|
||||||
|
const sortLabel = computed(() => {
|
||||||
|
if (sort.value === 'total') return '歌曲数'
|
||||||
|
if (sort.value === 'name') return '名称'
|
||||||
|
return '访问量'
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPlaylists = async () => {
|
||||||
|
const currentRequest = ++requestId
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await playlistStore.publicList(query.value, sort.value, page.value, pageSize)
|
||||||
|
if (currentRequest !== requestId) return
|
||||||
|
playlists.value = result.items || []
|
||||||
|
total.value = result.total || playlists.value.length
|
||||||
|
if (page.value > totalPages.value) page.value = totalPages.value
|
||||||
|
} catch (err: any) {
|
||||||
|
if (currentRequest !== requestId) return
|
||||||
|
playlists.value = []
|
||||||
|
total.value = 0
|
||||||
|
ElMessage.error(err?.message || '歌单广场加载失败')
|
||||||
|
} finally {
|
||||||
|
if (currentRequest === requestId) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleLoad = (delay = 600) => {
|
||||||
|
if (timer != null) window.clearTimeout(timer)
|
||||||
|
timer = window.setTimeout(loadPlaylists, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, () => {
|
||||||
|
page.value = 1
|
||||||
|
scheduleLoad(600)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(page, () => scheduleLoad(120))
|
||||||
|
|
||||||
|
const setSort = (nextSort: 'visit' | 'total' | 'name') => {
|
||||||
|
if (sort.value === nextSort) return
|
||||||
|
sort.value = nextSort
|
||||||
|
page.value = 1
|
||||||
|
scheduleLoad(120)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = (nextPage: number) => {
|
||||||
|
page.value = Math.max(1, Math.min(totalPages.value, nextPage))
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer != null) window.clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPlaylist = (id: string) => {
|
||||||
|
router.push({ name: 'PlaylistDetail', params: { scope: 'cloud', id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playlist-square-view {
|
||||||
|
min-height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 30px 32px 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
|
||||||
|
align-items: end;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
.section-meta,
|
||||||
|
.playlist-desc,
|
||||||
|
.playlist-meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar p {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tabs button,
|
||||||
|
.page-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tabs button.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tabs svg,
|
||||||
|
.playlist-meta svg,
|
||||||
|
.page-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-meta,
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 18px;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-bg-secondary));
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover svg {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-name {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 760;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-desc,
|
||||||
|
.playlist-name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 24px auto 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 234px;
|
||||||
|
background: linear-gradient(90deg, var(--color-bg-secondary), var(--color-bg-tertiary), var(--color-bg-secondary));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.2s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to { background-position: -220% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -66,24 +66,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="song-list">
|
<div class="song-list">
|
||||||
<div
|
<SongTile
|
||||||
class="song-item"
|
v-for="(song, i) in songs"
|
||||||
v-for="(song, i) in songs"
|
:key="`${song.source}:${song.id}:${i}`"
|
||||||
:key="song.id"
|
:song="song"
|
||||||
@click="handlePlaySong(i)"
|
:display-index="(currentPage - 1) * limit + i + 1"
|
||||||
>
|
:highlight="highlight"
|
||||||
<div class="song-index">{{ (currentPage - 1) * limit + i + 1 }}</div>
|
@play="handlePlaySong(i)"
|
||||||
<div class="song-cover">
|
/>
|
||||||
<img v-if="song.picUrl" :src="song.picUrl" loading="lazy" />
|
|
||||||
<div v-else class="cover-placeholder"></div>
|
|
||||||
</div>
|
|
||||||
<div class="song-info">
|
|
||||||
<h4 class="song-title" v-html="highlight(song.name)"></h4>
|
|
||||||
<p class="song-artist" v-html="highlight(song.artist)"></p>
|
|
||||||
</div>
|
|
||||||
<div class="song-album" v-html="highlight(song.albumName || '-')"></div>
|
|
||||||
<div class="song-duration">{{ song.duration }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
@@ -130,6 +120,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { usePlayerStore } from '../stores/player';
|
import { usePlayerStore } from '../stores/player';
|
||||||
import { transformSearchSong } from '../utils/songUtils';
|
import { transformSearchSong } from '../utils/songUtils';
|
||||||
|
import SongTile from '../components/SongTile.vue';
|
||||||
import type { Song } from '../types/song';
|
import type { Song } from '../types/song';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -345,11 +336,12 @@ onBeforeUnmount(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scroll-padding-bottom: 148px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 20px 30px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
|
padding: 20px 30px 148px; /* Reduced vertical padding, kept horizontal for spacing but flexible */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Removed max-width to allow full width usage as requested */
|
/* Removed max-width to allow full width usage as requested */
|
||||||
/* margin: 0 auto; */
|
/* margin: 0 auto; */
|
||||||
@@ -383,7 +375,7 @@ onBeforeUnmount(() => {
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 70%;
|
height: 70%;
|
||||||
background-color: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +553,7 @@ onBeforeUnmount(() => {
|
|||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 4px rgba(0,0,0,0.2);
|
box-shadow: 0 0 4px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
@@ -571,7 +563,7 @@ onBeforeUnmount(() => {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -797,7 +789,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pagination-btn.active {
|
.pagination-btn.active {
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
color: #fff; /* Ensure readable text on accent */
|
color: #fff; /* Ensure readable text on accent */
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -851,7 +843,7 @@ onBeforeUnmount(() => {
|
|||||||
.retry-btn {
|
.retry-btn {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 24px;
|
padding: 8px 24px;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent-gradient);
|
||||||
color: white; /* Ensure text is readable on accent color */
|
color: white; /* Ensure text is readable on accent color */
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
625
src/renderer/src/views/UserProfile.vue
Normal file
625
src/renderer/src/views/UserProfile.vue
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-profile-view" :class="{ 'is-loading': loading }">
|
||||||
|
<section class="profile-hero">
|
||||||
|
<div v-if="loading" class="hero-loading">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
</div>
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<img v-if="profile?.avatar" :src="profile.avatar" alt="" />
|
||||||
|
<Icon v-else icon="lucide:user" />
|
||||||
|
</div>
|
||||||
|
<div class="profile-copy">
|
||||||
|
<div class="eyebrow">{{ isOwnProfile ? 'MY PROFILE' : 'USER PROFILE' }}</div>
|
||||||
|
<h1>{{ displayName }}</h1>
|
||||||
|
<p class="user-id">@{{ profile?.username || profile?.id || routeUserId }}</p>
|
||||||
|
<p class="intro">{{ profile?.intro || '这个人还没有写简介。' }}</p>
|
||||||
|
<div class="profile-meta">
|
||||||
|
<span v-if="profile?.region">{{ profile.region }}</span>
|
||||||
|
<span v-if="profile?.gender">{{ profile.gender }}</span>
|
||||||
|
<span v-if="profile?.birthday && profile.birthday !== '????-??-??'">{{ profile.birthday }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="isOwnProfile" class="soft-btn" @click="openEditDialog">
|
||||||
|
<Icon icon="lucide:pencil" />
|
||||||
|
编辑资料
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-grid">
|
||||||
|
<div class="profile-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ playlistPanelTitle }}</h2>
|
||||||
|
<p>{{ playlists.length }} 个歌单</p>
|
||||||
|
</div>
|
||||||
|
<Icon icon="lucide:library" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="panel-empty">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="playlists.length === 0" class="panel-empty">{{ playlistEmptyText }}</div>
|
||||||
|
<template v-else>
|
||||||
|
<router-link
|
||||||
|
v-for="playlist in playlists"
|
||||||
|
:key="playlist.id"
|
||||||
|
class="playlist-row"
|
||||||
|
:to="{ name: 'PlaylistDetail', params: { scope: 'cloud', id: playlist.id } }"
|
||||||
|
>
|
||||||
|
<div class="mini-cover">
|
||||||
|
<img v-if="playlist.img" :src="playlist.img" alt="" />
|
||||||
|
<Icon v-else icon="lucide:cloud" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ playlist.name || '云歌单' }}</strong>
|
||||||
|
<span>{{ playlist.desc || '没有简介' }}</span>
|
||||||
|
<small>访问 {{ Number(playlist.visit_count || playlist.play_count || 0) || 0 }} 次</small>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="profile-panel clickable-panel"
|
||||||
|
:class="{ disabled: loading || favSongs.length === 0 }"
|
||||||
|
@click="openLikedPlaylist"
|
||||||
|
>
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>喜欢的歌</h2>
|
||||||
|
<p>{{ favSongs.length }} 首歌曲</p>
|
||||||
|
</div>
|
||||||
|
<Icon icon="lucide:heart" />
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="panel-empty">
|
||||||
|
<Icon icon="lucide:loader-2" class="spin" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="favSongs.length === 0" class="panel-empty">喜欢列表暂不可见或为空</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="song in favSongs.slice(0, 12)"
|
||||||
|
:key="`${song.source}:${song.id}`"
|
||||||
|
class="song-row"
|
||||||
|
>
|
||||||
|
<div class="song-thumb">
|
||||||
|
<img v-if="song.picUrl || song.pic" :src="song.picUrl || song.pic" alt="" />
|
||||||
|
<Icon v-else icon="lucide:music" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{{ song.name || '未知歌曲' }}</strong>
|
||||||
|
<span>{{ song.artist || song.artists || '未知歌手' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-if="favSongs.length > 12" class="show-more-btn" @click.stop="openLikedPlaylist">
|
||||||
|
显示更多
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showEditDialog" class="dialog-backdrop" @click.self="showEditDialog = false">
|
||||||
|
<div class="edit-dialog">
|
||||||
|
<div class="dialog-title">编辑资料</div>
|
||||||
|
<input v-model="draft.nickname" class="text-input" placeholder="昵称" />
|
||||||
|
<textarea v-model="draft.intro" class="text-area" placeholder="简介"></textarea>
|
||||||
|
<div class="field-grid">
|
||||||
|
<input v-model="draft.gender" class="text-input" placeholder="性别" />
|
||||||
|
<input v-model="draft.region" class="text-input" placeholder="地区" />
|
||||||
|
<input v-model="draft.birthday" class="text-input" placeholder="生日" />
|
||||||
|
<input v-model="draft.avatar" class="text-input" placeholder="头像 URL" />
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="ghost-btn" @click="showEditDialog = false">取消</button>
|
||||||
|
<button class="primary-btn compact" :disabled="savingProfile" @click="saveProfile">
|
||||||
|
<Icon v-if="savingProfile" icon="lucide:loader-2" class="spin" />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAuthStore, type UserInfo } from '../stores/auth'
|
||||||
|
|
||||||
|
type PublicPlaylist = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
desc?: string
|
||||||
|
img?: string
|
||||||
|
total?: number
|
||||||
|
play_count?: string
|
||||||
|
visit_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicSong = {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
artist?: string
|
||||||
|
artists?: string
|
||||||
|
source: string
|
||||||
|
pic?: string
|
||||||
|
picUrl?: string
|
||||||
|
interval?: string
|
||||||
|
duration?: string
|
||||||
|
albumName?: string | null
|
||||||
|
albumId?: string | null
|
||||||
|
quality?: string
|
||||||
|
qualities?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const profile = ref<UserInfo | null>(null)
|
||||||
|
const playlists = ref<PublicPlaylist[]>([])
|
||||||
|
const favSongs = ref<PublicSong[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showEditDialog = ref(false)
|
||||||
|
const savingProfile = ref(false)
|
||||||
|
|
||||||
|
const draft = reactive({
|
||||||
|
nickname: '',
|
||||||
|
intro: '',
|
||||||
|
gender: '',
|
||||||
|
region: '',
|
||||||
|
birthday: '',
|
||||||
|
avatar: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeUserId = computed(() => String(route.params.id || authStore.state.userInfo?.id || ''))
|
||||||
|
const isOwnProfile = computed(() => Boolean(authStore.state.userInfo?.id && authStore.state.userInfo.id === routeUserId.value))
|
||||||
|
const displayName = computed(() => profile.value?.nickname || profile.value?.username || '用户')
|
||||||
|
const playlistPanelTitle = computed(() => isOwnProfile.value ? '我的歌单' : '公开歌单')
|
||||||
|
const playlistEmptyText = computed(() => isOwnProfile.value ? '还没有创建歌单' : '暂无可查看的公开歌单')
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
if (!routeUserId.value) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
profile.value = await window.electronAPI.user.getProfile(routeUserId.value)
|
||||||
|
const [playlistResult, favResult] = await Promise.allSettled([
|
||||||
|
window.electronAPI.user.getPlaylists(routeUserId.value),
|
||||||
|
window.electronAPI.user.getFavSongs(routeUserId.value),
|
||||||
|
])
|
||||||
|
playlists.value = playlistResult.status === 'fulfilled' ? playlistResult.value : []
|
||||||
|
favSongs.value = favResult.status === 'fulfilled' ? favResult.value : []
|
||||||
|
if (route.query.edit === '1' && isOwnProfile.value) openEditDialog()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '用户资料加载失败')
|
||||||
|
profile.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = () => {
|
||||||
|
if (!isOwnProfile.value || !profile.value) return
|
||||||
|
draft.nickname = profile.value.nickname || ''
|
||||||
|
draft.intro = profile.value.intro || ''
|
||||||
|
draft.gender = profile.value.gender || ''
|
||||||
|
draft.region = profile.value.region || ''
|
||||||
|
draft.birthday = profile.value.birthday || ''
|
||||||
|
draft.avatar = profile.value.avatar || ''
|
||||||
|
showEditDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
savingProfile.value = true
|
||||||
|
try {
|
||||||
|
const updated = await window.electronAPI.user.updateProfile({
|
||||||
|
nickname: draft.nickname,
|
||||||
|
intro: draft.intro,
|
||||||
|
gender: draft.gender,
|
||||||
|
region: draft.region,
|
||||||
|
birthday: draft.birthday,
|
||||||
|
avatar: draft.avatar,
|
||||||
|
})
|
||||||
|
profile.value = updated
|
||||||
|
authStore.applyUserInfo(updated)
|
||||||
|
showEditDialog.value = false
|
||||||
|
ElMessage.success('资料已更新')
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '资料更新失败')
|
||||||
|
} finally {
|
||||||
|
savingProfile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLikedPlaylist = () => {
|
||||||
|
if (loading.value || favSongs.value.length === 0 || !routeUserId.value) return
|
||||||
|
router.push({ name: 'UserLikedPlaylist', params: { id: routeUserId.value } })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [route.params.id, route.query.edit, authStore.state.userInfo?.id], loadProfile, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-profile-view {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 30px 32px 148px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero {
|
||||||
|
min-height: 228px;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 24px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 0%, color-mix(in srgb, var(--color-accent) 20%, transparent), transparent 36%),
|
||||||
|
color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
position: relative;
|
||||||
|
animation: profile-enter 260ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
right: 22px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap {
|
||||||
|
width: 116px;
|
||||||
|
height: 116px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
color: var(--color-accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-copy {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1.08;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id,
|
||||||
|
.intro,
|
||||||
|
.profile-meta,
|
||||||
|
.panel-head p,
|
||||||
|
.playlist-row span,
|
||||||
|
.playlist-row small,
|
||||||
|
.song-row span,
|
||||||
|
.panel-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
max-width: 620px;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
margin-top: 22px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel {
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-secondary) 78%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||||
|
animation: profile-enter 280ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel:nth-child(2) {
|
||||||
|
animation-delay: 45ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-panel {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-panel:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 7%, var(--color-bg-secondary));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-panel.disabled {
|
||||||
|
cursor: default;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head svg {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-row,
|
||||||
|
.song-row {
|
||||||
|
min-height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-row {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-row:hover,
|
||||||
|
.song-row:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-cover,
|
||||||
|
.song-thumb {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-thumb {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-cover img,
|
||||||
|
.song-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-row div:last-child,
|
||||||
|
.song-row div:last-child {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-row strong,
|
||||||
|
.playlist-row span,
|
||||||
|
.playlist-row small,
|
||||||
|
.song-row strong,
|
||||||
|
.song-row span {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-row small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-empty {
|
||||||
|
min-height: 210px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-more-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-more-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 13%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-btn,
|
||||||
|
.primary-btn,
|
||||||
|
.ghost-btn {
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-btn,
|
||||||
|
.ghost-btn {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-btn:hover,
|
||||||
|
.ghost-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
padding: 0 18px;
|
||||||
|
background: var(--color-accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn.compact {
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-dialog {
|
||||||
|
width: min(460px, calc(100vw - 32px));
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 750;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input,
|
||||||
|
.text-area {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
outline: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-area {
|
||||||
|
min-height: 92px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes profile-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.profile-hero {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-btn {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user