diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml index ee4dfcd..48b011a 100644 --- a/.github/workflows/artifacts.yml +++ b/.github/workflows/artifacts.yml @@ -2,7 +2,7 @@ name: Artifacts on: push: - branches: [main, master] + # 任意分支推送都构建四平台产物(docs 等纯文档改动除外) paths-ignore: - '**/*.md' - '**/*.mdx' diff --git a/.gitignore b/.gitignore index 32b7d55..e4752fa 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ docs-private/ # 内部审查/对齐文档只留本地,不入库 .report/ docs/ + +# Arch 打包工作目录(makepkg 产物与本地 PKGBUILD) +/build/ diff --git a/scripts/run-tauri.mjs b/scripts/run-tauri.mjs index 076d2a9..af965a5 100644 --- a/scripts/run-tauri.mjs +++ b/scripts/run-tauri.mjs @@ -1,11 +1,48 @@ import { spawn } from 'node:child_process' import { createRequire } from 'node:module' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' const require = createRequire(import.meta.url) const tauriCli = require.resolve('@tauri-apps/cli/tauri.js') + +// linuxdeploy bundles an old strip (no SHT_RELR support) that fails on +// distros whose system libs use RELR relocations (Arch/Fedora families): +// "unknown type [0x13] section `.relr.dyn'". NO_STRIP skips stripping +// entirely; only affects Linux AppImage bundling. +function isRelrDistro() { + try { + const release = readFileSync('/etc/os-release', 'utf8') + return /^ID(?:_LIKE)?=.*\b(?:arch|fedora)\b/m.test(release) + } catch { + return false + } +} + +// linuxdeploy-plugin-gtk 的符号链接兜底(linuxdeploy/linuxdeploy-plugin-gtk#24 +// 方案)不带 -f:linuxdeploy 已把 GTK 模块铺平到 $APPDIR/usr/lib 时, +// `ln: 文件已存在` 会让插件 set -e 直接失败(Arch 等发行版必现)。 +// 幂等打补丁:ln -s -> ln -sf,符号链接目标不变,可安全覆盖平铺副本。 +function patchGtkPluginSymlinks() { + try { + const cacheDir = process.env.XDG_CACHE_HOME + ? `${process.env.XDG_CACHE_HOME}/tauri` + : `${process.env.HOME || ''}/.cache/tauri` + const pluginPath = `${cacheDir}/linuxdeploy-plugin-gtk.sh` + if (!existsSync(pluginPath)) return + const content = readFileSync(pluginPath, 'utf8') + const patched = content.replace('ln $verbose -s ', 'ln $verbose -sf ') + if (patched !== content) writeFileSync(pluginPath, patched) + } catch { + // 打补丁失败不阻塞构建(仅影响 AppImage 打包) + } +} + +if (process.platform === 'linux') patchGtkPluginSymlinks() + const environment = { ...process.env, NERI_BUILD_EPOCH: process.env.NERI_BUILD_EPOCH || Math.floor(Date.now() / 1000).toString(), + ...(isRelrDistro() ? { NO_STRIP: '1' } : {}), } const child = spawn(process.execPath, [tauriCli, ...process.argv.slice(2)], { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a65da3f..e706ee6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3019,6 +3019,7 @@ dependencies = [ "uuid", "walkdir", "whoami", + "winreg", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e5ccc1..cf68101 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset"] } +tauri = { version = "2", features = ["protocol-asset", "tray-icon"] } tauri-plugin-opener = "2.5.4" tauri-plugin-store = "2" tauri-plugin-dialog = "2" @@ -77,3 +77,6 @@ opt-level = 2 [profile.dev.package.symphonia-format-ogg] opt-level = 2 + +[target.'cfg(windows)'.dependencies] +winreg = "0.55" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c7e8f3f..9e0de4b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,3 +12,4 @@ pub mod settings_cmd; pub mod stats_cmd; pub mod storage_cmd; pub mod sync_cmd; +pub mod tray_cmd; diff --git a/src-tauri/src/commands/settings_cmd.rs b/src-tauri/src/commands/settings_cmd.rs index 2995097..8465e44 100644 --- a/src-tauri/src/commands/settings_cmd.rs +++ b/src-tauri/src/commands/settings_cmd.rs @@ -352,6 +352,35 @@ pub async fn get_build_info() -> AppResult { }) } +/// 读取系统强调色,供「自动跟随系统取色」使用,返回 "rgb(r, g, b)"。 +/// Windows 从注册表 Explorer\Accent 读取 SystemAccentColor(ABGR,Win10 1903+), +/// 旧系统回退 AccentColorMenu;其余平台返回 None(由前端 CSS accent 关键字兜底)。 +#[tauri::command] +pub fn get_system_accent_color() -> Option { + #[cfg(target_os = "windows")] + { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let accent = hkcu + .open_subkey(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Accent") + .ok()?; + let raw: u32 = accent + .get_value("SystemAccentColor") + .or_else(|_| accent.get_value("AccentColorMenu")) + .ok()?; + let r = raw & 0xFF; + let g = (raw >> 8) & 0xFF; + let b = (raw >> 16) & 0xFF; + return Some(format!("rgb({r}, {g}, {b})")); + } + #[cfg(not(target_os = "windows"))] + { + None + } +} + #[cfg(test)] mod tests { use super::{ diff --git a/src-tauri/src/commands/sync_cmd.rs b/src-tauri/src/commands/sync_cmd.rs index 00afd85..026a8a0 100644 --- a/src-tauri/src/commands/sync_cmd.rs +++ b/src-tauri/src/commands/sync_cmd.rs @@ -1,7 +1,9 @@ // 同步相关命令 +use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::{AppHandle, Emitter, Manager, State}; +use crate::auth::state::{AuthState, BiliAuth, CookieEntry, NeteaseAuth, YouTubeAuth}; use crate::error::{AppError, AppResult}; use crate::settings::store::{self, AppSettings}; use crate::state::AppState; @@ -180,6 +182,372 @@ impl ConfigWebDavSync { } } +// ---- Android 导出配置(AppConfigBackup.kt)兼容导入 ---- + +/// Android 端 settings 是 DataStore 类型分组快照(TypedPreferenceSnapshot)。 +/// ints 分组暂无与桌面语义相同的键,不读取(serde 自动忽略未知键)。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidTypedSettings { + #[serde(default)] + booleans: HashMap, + #[serde(default)] + floats: HashMap, + #[serde(default)] + longs: HashMap, + #[serde(default)] + strings: HashMap, +} + +impl AndroidTypedSettings { + /// 把 Android 分组偏好叠加到桌面设置上(仅映射两端语义相同的键,未知键忽略) + fn apply_to(&self, settings: &mut AppSettings) { + for (key, value) in &self.booleans { + match key.as_str() { + "dev_mode_enabled" => settings.dev_mode_enabled = *value, + "internationalization_enabled" => settings.internationalization_enabled = *value, + "bypass_proxy" => settings.bypass_proxy = *value, + // Android 的 dynamic_color = Material You 跟随系统取色, + // 语义与桌面旧 dynamic_color(跟随封面)不同,直接映射 color_mode + "dynamic_color" => { + settings.color_mode = if *value { "system" } else { "default" }.into() + } + "nowplaying_audio_reactive_enabled" => settings.audio_reactive = *value, + "nowplaying_dynamic_background_enabled" => settings.dynamic_background = *value, + "nowplaying_cover_blur_background_enabled" => settings.cover_blur_bg = *value, + "nowplaying_toolbar_dock_enabled" => settings.show_toolbar_dock = *value, + _ => {} + } + } + for (key, value) in &self.floats { + match key.as_str() { + "lyric_font_scale" => settings.lyric_font_scale = *value, + "lyric_blur_amount" => settings.lyric_blur_amount = *value, + "nowplaying_cover_blur_amount" => settings.cover_blur_amount = *value, + _ => {} + } + } + for (key, value) in &self.longs { + match key.as_str() { + "cloud_music_lyric_default_offset_ms" => settings.cloud_music_offset = *value as i32, + // Android 以字节存储缓存上限,桌面端以 MB 为单位 + "max_cache_size_bytes" => settings.max_cache_size = (*value / 1024 / 1024) as i32, + _ => {} + } + } + for (key, value) in &self.strings { + match key.as_str() { + "audio_quality" => settings.netease_quality = value.clone(), + "youtube_audio_quality" => settings.youtube_quality = value.clone(), + "bili_audio_quality" => settings.bili_quality = value.clone(), + _ => {} + } + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidListenTogether { + #[serde(default)] + worker_base_url: String, + #[serde(default)] + user_uuid: String, + #[serde(default)] + nickname: String, + #[serde(default = "config_default_true")] + allow_member_control: bool, + #[serde(default = "config_default_true")] + auto_pause_on_member_change: bool, + #[serde(default = "config_default_true")] + share_audio_links: bool, +} + +impl From for ConfigListenTogether { + fn from(value: AndroidListenTogether) -> Self { + Self { + user_uuid: value.user_uuid, + server_url: value.worker_base_url, + nickname: value.nickname, + allow_member_control: value.allow_member_control, + auto_pause_on_member_change: value.auto_pause_on_member_change, + share_audio_links: value.share_audio_links, + } + } +} + +/// 网易云 / B站 的 Cookie 快照(SavedCookieConfigSnapshot) +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidCookieAuth { + #[serde(default)] + cookies: HashMap, +} + +/// YouTube 的 Cookie 快照(YouTubeAuthConfigSnapshot,只取 cookies) +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidYouTubeAuth { + #[serde(default)] + cookies: HashMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidGitHubSync { + #[serde(default)] + token: String, + #[serde(default)] + repo_owner: String, + #[serde(default)] + repo_name: String, + #[serde(default)] + auto_sync_enabled: bool, + #[serde(default)] + play_history_update_mode: String, + #[serde(default = "config_default_true")] + data_saver_mode: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidWebDavSync { + #[serde(default)] + server_url: String, + #[serde(default)] + base_path: String, + #[serde(default)] + username: String, + #[serde(default)] + password: String, + #[serde(default)] + auto_sync_enabled: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidSyncPreferences { + #[serde(default)] + play_history_update_mode: String, +} + +/// Android 端导出的配置备份(字段与 AppConfigBackup.kt 对齐) +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AndroidConfigFile { + #[serde(default)] + kind: String, + #[serde(default)] + format_version: u32, + #[serde(default)] + settings: AndroidTypedSettings, + #[serde(default)] + listen_together: Option, + #[serde(default)] + language: Option, + #[serde(default)] + netease_auth: Option, + #[serde(default)] + bili_auth: Option, + #[serde(default)] + you_tube_auth: Option, + #[serde(default)] + git_hub_sync: Option, + #[serde(default)] + web_dav_sync: Option, + #[serde(default)] + sync_preferences: Option, +} + +/// Android 与 PC 配置的判别:Android 的 settings 是类型分组 +/// (booleans/floats/...),PC 是扁平 AppSettings,无 "booleans" 键 +fn is_android_config(root: &Value) -> bool { + root.get("settings") + .and_then(|settings| settings.get("booleans")) + .is_some() +} + +fn android_cookie_entries(cookies: &HashMap, domain: &str) -> Vec { + cookies + .iter() + .filter(|(name, value)| !name.is_empty() && !value.is_empty()) + .map(|(name, value)| CookieEntry { + name: name.clone(), + value: value.clone(), + domain: domain.into(), + }) + .collect() +} + +/// Android 三平台 Cookie 快照 → 桌面 AuthState(不包含用户资料字段) +fn map_android_auth(config: &AndroidConfigFile) -> Option { + let netease = config + .netease_auth + .as_ref() + .filter(|auth| !auth.cookies.is_empty()) + .map(|auth| NeteaseAuth { + cookies: android_cookie_entries(&auth.cookies, "music.163.com"), + user_id: None, + nickname: None, + avatar_url: None, + }); + let bilibili = config + .bili_auth + .as_ref() + .filter(|auth| !auth.cookies.is_empty()) + .map(|auth| BiliAuth { + cookies: android_cookie_entries(&auth.cookies, ".bilibili.com"), + mid: None, + nickname: None, + avatar_url: None, + }); + let youtube = config + .you_tube_auth + .as_ref() + .filter(|auth| !auth.cookies.is_empty()) + .map(|auth| YouTubeAuth { + cookies: android_cookie_entries(&auth.cookies, "music.youtube.com"), + nickname: None, + avatar_url: None, + }); + if netease.is_none() && bilibili.is_none() && youtube.is_none() { + None + } else { + Some(AuthState { + netease, + bilibili, + youtube, + }) + } +} + +/// Android 导出总带全部分组;空段落不覆盖桌面已有配置(避免误清凭据)。 +/// 以下映射函数在有实际内容时才返回 Some。 +fn map_android_language(language: Option) -> Option { + language.filter(|language| !language.code.is_empty()).map(|mut language| { + // Android 语言码是 zh/en/ja,桌面端是 zh-CN/zh-TW/en/ja + if language.code == "zh" { + language.code = "zh-CN".into(); + } + language + }) +} + +fn map_android_listen_together( + listen_together: Option, +) -> Option { + listen_together + .filter(|lt| { + !lt.worker_base_url.is_empty() || !lt.user_uuid.is_empty() || !lt.nickname.is_empty() + }) + .map(Into::into) +} + +fn map_android_github(github: Option) -> Option { + github + .filter(|github| { + !github.token.is_empty() + || !github.repo_owner.is_empty() + || !github.repo_name.is_empty() + || !github.play_history_update_mode.is_empty() + }) + .map(|github| ConfigGitHubSync { + token: github.token, + owner: github.repo_owner, + repo: github.repo_name, + last_remote_sha: String::new(), + last_sync_time: 0, + auto_sync: github.auto_sync_enabled, + data_saver: github.data_saver_mode, + silent_failures: false, + history_update_mode: normalize_history_update_mode(&github.play_history_update_mode), + }) +} + +fn map_android_webdav(webdav: Option) -> Option { + webdav + .filter(|webdav| { + !webdav.server_url.is_empty() || !webdav.username.is_empty() || !webdav.password.is_empty() + }) + .map(|webdav| ConfigWebDavSync { + server_url: webdav.server_url, + username: webdav.username, + password: webdav.password, + base_path: webdav.base_path, + last_remote_fingerprint: String::new(), + last_sync_time: 0, + auto_sync: webdav.auto_sync_enabled, + data_saver: true, + }) +} + +fn map_android_sync_preferences( + preferences: Option, +) -> Option { + preferences + .filter(|preferences| !preferences.play_history_update_mode.is_empty()) + .map(|preferences| ConfigSyncPreferences { + history_update_mode: normalize_history_update_mode(&preferences.play_history_update_mode), + }) +} + +/// 归一化后的待导入配置(PC 与 Android 两种来源共用) +struct ImportedConfig { + settings: AppSettings, + listen_together: Option, + language: Option, + auth: Option, + github_sync: Option, + webdav_sync: Option, + sync_preferences: Option, +} + +/// 解析桌面端导出的配置文件 +fn parse_pc_config(root: Value) -> AppResult { + let payload: DesktopConfigFile = serde_json::from_value(root) + .map_err(|e| AppError::Other(format!("Parse config failed: {}", e)))?; + if payload.kind != CONFIG_FILE_KIND + || (payload.format_version != 0 && payload.format_version > CONFIG_FILE_VERSION) + || payload.platform != "pc" + { + return Err(AppError::Other("Unsupported config file".into())); + } + Ok(ImportedConfig { + settings: payload.settings, + listen_together: payload.listen_together, + language: payload.language, + auth: payload.auth, + github_sync: payload.github_sync, + webdav_sync: payload.webdav_sync, + sync_preferences: payload.sync_preferences, + }) +} + +/// 解析 Android 端导出的配置备份,偏好叠加到桌面当前设置上 +fn parse_android_config(app: &AppHandle, root: Value) -> AppResult { + let payload: AndroidConfigFile = serde_json::from_value(root) + .map_err(|e| AppError::Other(format!("Parse android config failed: {}", e)))?; + if payload.kind != CONFIG_FILE_KIND + || (payload.format_version != 0 && payload.format_version > CONFIG_FILE_VERSION) + { + return Err(AppError::Other("Unsupported config file".into())); + } + + let mut settings = store::load_settings(app)?.settings; + payload.settings.apply_to(&mut settings); + + let auth = map_android_auth(&payload); + Ok(ImportedConfig { + settings, + listen_together: map_android_listen_together(payload.listen_together), + language: map_android_language(payload.language), + auth, + github_sync: map_android_github(payload.git_hub_sync), + webdav_sync: map_android_webdav(payload.web_dav_sync), + sync_preferences: map_android_sync_preferences(payload.sync_preferences), + }) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopConfigFile { @@ -200,7 +568,7 @@ struct DesktopConfigFile { #[serde(default)] language: Option, #[serde(default)] - auth: Option, + auth: Option, #[serde(default)] github_sync: Option, #[serde(default)] @@ -1046,18 +1414,18 @@ pub async fn import_config(app: AppHandle, state: State<'_, AppState>) -> AppRes } let content = std::fs::read_to_string(file_path) .map_err(|e| AppError::Other(format!("Read config failed: {}", e)))?; - let payload: DesktopConfigFile = serde_json::from_str(&content) + let root: Value = serde_json::from_str(&content) .map_err(|e| AppError::Other(format!("Parse config failed: {}", e)))?; - if payload.kind != CONFIG_FILE_KIND - || (payload.format_version != 0 && payload.format_version > CONFIG_FILE_VERSION) - || payload.platform != "pc" - { - return Err(AppError::Other("Unsupported config file".into())); - } - let imported_listen_together = payload.listen_together; - let imported_language = payload.language; - let mut settings = payload.settings; + let imported = if is_android_config(&root) { + parse_android_config(&app, root)? + } else { + parse_pc_config(root)? + }; + + let imported_listen_together = imported.listen_together; + let imported_language = imported.language; + let mut settings = imported.settings; if let Some(language) = imported_language.as_ref().filter(|language| !language.code.is_empty()) { settings.locale = language.code.clone(); } @@ -1073,7 +1441,7 @@ pub async fn import_config(app: AppHandle, state: State<'_, AppState>) -> AppRes let settings = store::save_settings(&app, settings)?; state.rebuild_http(settings.bypass_proxy); - if let Some(imported_auth) = payload.auth { + if let Some(imported_auth) = imported.auth { let mut auth = state.auth.lock(); let previous_auth = auth.clone(); for platform in ["netease", "bilibili", "youtube"] { @@ -1087,21 +1455,21 @@ pub async fn import_config(app: AppHandle, state: State<'_, AppState>) -> AppRes crate::auth::cookies::inject_all(&state.cookie_jar, &auth); crate::auth::cookies::save_auth(&app, &auth); } - let legacy_history_mode = payload + let legacy_history_mode = imported .github_sync .as_ref() .map(|github| github.history_update_mode.clone()); - if let Some(preferences) = payload.sync_preferences { + if let Some(preferences) = imported.sync_preferences { save_sync_preferences(&app, &preferences.into_config()); } else if let Some(mode) = legacy_history_mode { save_sync_preferences(&app, &SyncPreferencesConfig { history_update_mode: normalize_history_update_mode(&mode), }); } - if let Some(github) = payload.github_sync { + if let Some(github) = imported.github_sync { save_github_config(&app, &github.into_config()); } - if let Some(webdav) = payload.webdav_sync { + if let Some(webdav) = imported.webdav_sync { save_webdav_config(&app, &webdav.into_config()); } @@ -1195,4 +1563,141 @@ mod tests { assert_eq!(value["historyUpdateMode"], "every_15_minutes"); } + + #[test] + fn android_settings_apply_maps_known_keys_only() { + use super::AndroidTypedSettings; + let android = AndroidTypedSettings { + booleans: [("bypass_proxy".into(), true), ("usb_exclusive_playback".into(), true)] + .into_iter() + .collect(), + floats: [("lyric_font_scale".into(), 1.25)].into_iter().collect(), + longs: [ + ("cloud_music_lyric_default_offset_ms".into(), 250), + ("max_cache_size_bytes".into(), 10_737_418_240), + ] + .into_iter() + .collect(), + strings: [ + ("audio_quality".into(), "jymaster".into()), + ("youtube_audio_quality".into(), "low".into()), + ("theme_color_spec".into(), "SPEC_2025".into()), + ] + .into_iter() + .collect(), + }; + let mut settings = AppSettings::default(); + android.apply_to(&mut settings); + + assert!(settings.bypass_proxy); + assert!((settings.lyric_font_scale - 1.25).abs() < f32::EPSILON); + assert_eq!(settings.cloud_music_offset, 250); + assert_eq!(settings.max_cache_size, 10_240); + assert_eq!(settings.netease_quality, "jymaster"); + assert_eq!(settings.youtube_quality, "low"); + // 未知键与桌面无关键不产生副作用 + assert!(settings.dynamic_background); + assert_eq!(settings.theme_color, "purple"); + } + + #[test] + fn android_dynamic_color_maps_to_system_mode() { + use super::AndroidTypedSettings; + let android = AndroidTypedSettings { + booleans: [("dynamic_color".into(), true)].into_iter().collect(), + floats: Default::default(), + longs: Default::default(), + strings: Default::default(), + }; + let mut settings = AppSettings::default(); + android.apply_to(&mut settings); + // Android 的 dynamic_color = Material You 跟随系统取色 + assert_eq!(settings.color_mode, "system"); + + let android = AndroidTypedSettings { + booleans: [("dynamic_color".into(), false)].into_iter().collect(), + floats: Default::default(), + longs: Default::default(), + strings: Default::default(), + }; + let mut settings = AppSettings::default(); + android.apply_to(&mut settings); + assert_eq!(settings.color_mode, "default"); + } + + #[test] + fn android_config_file_parses_and_maps() { + use super::{ + is_android_config, map_android_auth, AndroidConfigFile, ConfigListenTogether, + }; + let json = r#"{ + "kind": "moe.ouom.neriplayer.config", + "formatVersion": 1, + "exportedAt": 1787906559291, + "settings": { "booleans": { "bypass_proxy": true }, "floats": {}, "ints": {}, "longs": {}, "strings": {} }, + "listenTogether": { "workerBaseUrl": "https://lt.example", "userUuid": "abc", "nickname": "me" }, + "language": { "code": "zh" }, + "neteaseAuth": { "cookies": { "MUSIC_U": "v1", "__csrf": "" }, "savedAt": 1 }, + "biliAuth": { "cookies": {}, "savedAt": 0 }, + "youTubeAuth": { "cookies": { "SAPISID": "s1" }, "authorization": "SAPISIDHASH x", "savedAt": 1 }, + "gitHubSync": { "token": "", "repoOwner": "", "repoName": "", "autoSyncEnabled": true, "playHistoryUpdateMode": "", "dataSaverMode": true }, + "webDavSync": { "serverUrl": "https://dav.example", "basePath": "", "username": "u", "password": "p", "autoSyncEnabled": true }, + "syncPreferences": { "playHistoryUpdateMode": "IMMEDIATE" } + }"#; + let root: serde_json::Value = serde_json::from_str(json).unwrap(); + assert!(is_android_config(&root)); + + let config: AndroidConfigFile = serde_json::from_value(root).unwrap(); + assert_eq!(config.kind, CONFIG_FILE_KIND); + assert_eq!(config.format_version, 1); + assert_eq!( + config.listen_together.as_ref().unwrap().worker_base_url, + "https://lt.example" + ); + assert_eq!(config.language.as_ref().unwrap().code, "zh"); + assert!(config.netease_auth.as_ref().unwrap().cookies.contains_key("MUSIC_U")); + assert!(config.bili_auth.as_ref().unwrap().cookies.is_empty()); + assert!(config.git_hub_sync.is_some()); + assert_eq!(config.web_dav_sync.as_ref().unwrap().server_url, "https://dav.example"); + + let lt: ConfigListenTogether = config.listen_together.clone().unwrap().into(); + assert_eq!(lt.server_url, "https://lt.example"); + assert_eq!(lt.user_uuid, "abc"); + + let auth = map_android_auth(&config).unwrap(); + assert!(auth.netease.is_some()); + assert!(auth.bilibili.is_none()); + assert!(auth.youtube.is_some()); + // 空值 Cookie 被过滤 + assert_eq!(auth.netease.as_ref().unwrap().cookies.len(), 1); + assert_eq!(auth.netease.as_ref().unwrap().cookies[0].domain, "music.163.com"); + assert_eq!(auth.youtube.as_ref().unwrap().cookies[0].domain, "music.youtube.com"); + } + + #[test] + fn android_empty_sections_do_not_clobber_existing_config() { + use super::{ + map_android_auth, map_android_github, map_android_language, + map_android_listen_together, map_android_sync_preferences, map_android_webdav, + AndroidConfigFile, + }; + let json = r#"{ + "kind": "moe.ouom.neriplayer.config", + "formatVersion": 1, + "settings": { "booleans": {}, "floats": {}, "ints": {}, "longs": {}, "strings": {} }, + "listenTogether": { "workerBaseUrl": "", "userUuid": "", "nickname": "" }, + "language": { "code": "" }, + "gitHubSync": { "token": "", "repoOwner": "", "repoName": "", "autoSyncEnabled": true, "playHistoryUpdateMode": "", "dataSaverMode": true }, + "webDavSync": { "serverUrl": "", "basePath": "", "username": "", "password": "", "autoSyncEnabled": true }, + "syncPreferences": { "playHistoryUpdateMode": "" } + }"#; + let config: AndroidConfigFile = serde_json::from_value(serde_json::from_str(json).unwrap()) + .unwrap(); + assert!(map_android_listen_together(config.listen_together.clone()).is_none()); + assert!(map_android_language(config.language.clone()).is_none()); + assert!(map_android_github(config.git_hub_sync.clone()).is_none()); + assert!(map_android_webdav(config.web_dav_sync.clone()).is_none()); + assert!(map_android_sync_preferences(config.sync_preferences.clone()).is_none()); + assert!(map_android_auth(&config).is_none()); + } } diff --git a/src-tauri/src/commands/tray_cmd.rs b/src-tauri/src/commands/tray_cmd.rs new file mode 100644 index 0000000..b27a019 --- /dev/null +++ b/src-tauri/src/commands/tray_cmd.rs @@ -0,0 +1,183 @@ +// 托盘菜单多语言:文案统一存放在前端 i18n 资源(src/i18n/*.json), +// 前端 setLocale 时把当前语言已翻译的文案通过 set_tray_texts 下发, +// Rust 侧不维护任何语言数据,只负责应用与存模板 +use std::sync::Mutex; +use tauri::menu::MenuItem; +use tauri::AppHandle; + +pub struct TrayMenuHandles { + pub prev: MenuItem, + pub toggle: MenuItem, + pub next: MenuItem, + pub now_playing: MenuItem, + pub home: MenuItem, + pub quit: MenuItem, +} + +/// 前端下发的最新托盘文案(含「正在播放」裸标签与「正在播放:{title}」模板) +struct TrayTexts { + now_playing: String, + now_playing_title: String, +} + +struct TrayState { + app: AppHandle, + handles: TrayMenuHandles, + texts: Option, + /// 最近一次同步的曲目名:文案更新(语言切换)后重绘用 + current_title: String, +} + +// 单一锁:句柄/文案/曲目名同锁保护,避免多锁嵌套顺序不一致导致的死锁 +static TRAY: Mutex> = Mutex::new(None); + +/// GTK(Linux)菜单项必须在主线程变更,跨线程 set_text 会静默失效; +/// 统一经 run_on_main_thread 派发(Windows/macOS 同样安全) +fn apply_item_text(app: &AppHandle, item: MenuItem, text: String) { + let _ = app.run_on_main_thread(move || { + let _ = item.set_text(text); + }); +} + +/// 渲染「正在播放」项文案:空标题用裸标签,否则按模板替换(超长截断 40 字) +fn format_now_playing(title: &str, now_playing: &str, title_template: &str) -> String { + let trimmed = title.trim(); + if trimmed.is_empty() { + return now_playing.to_string(); + } + let truncated: String = trimmed.chars().take(40).collect(); + let truncated = if truncated.chars().count() < trimmed.chars().count() { + format!("{truncated}…") + } else { + truncated + }; + title_template.replace("{title}", &truncated) +} + +/// 根据当前状态计算「正在播放」项文案(无文案下发时返回 None,暂不更新) +fn render_now_playing(state: &TrayState) -> Option { + let texts = state.texts.as_ref()?; + Some(format_now_playing( + &state.current_title, + &texts.now_playing, + &texts.now_playing_title, + )) +} + +/// 托盘菜单创建完成后注册句柄与 AppHandle(main.rs setup 调用一次) +pub fn register_tray_handles(app: &AppHandle, handles: TrayMenuHandles) { + *TRAY.lock().unwrap() = Some(TrayState { + app: app.clone(), + handles, + texts: None, + current_title: String::new(), + }); +} + +/// 应用语言变化时同步托盘菜单文案(前端 setLocale 调用,文案由 i18n 资源翻译)。 +/// 更新后按当前曲目名重绘「正在播放:xxx」,避免语言切换把标题刷掉 +#[tauri::command] +pub fn set_tray_texts( + prev: String, + toggle: String, + next: String, + now_playing: String, + now_playing_title: String, + open_home: String, + quit: String, +) { + let mut guard = TRAY.lock().unwrap(); + let Some(state) = guard.as_mut() else { + return; + }; + log::info!(target: "tray", "set_tray_texts 已接收文案: nowPlaying={now_playing} template={now_playing_title}"); + let app = state.app.clone(); + let prev_item = state.handles.prev.clone(); + let toggle_item = state.handles.toggle.clone(); + let next_item = state.handles.next.clone(); + let now_item = state.handles.now_playing.clone(); + let home_item = state.handles.home.clone(); + let quit_item = state.handles.quit.clone(); + state.texts = Some(TrayTexts { + now_playing, + now_playing_title, + }); + drop(guard); + // 主线程统一应用文案(含按当前曲目名重绘的「正在播放」项) + apply_item_text(&app, prev_item, prev); + apply_item_text(&app, toggle_item, toggle); + apply_item_text(&app, next_item, next); + apply_item_text(&app, home_item, open_home); + apply_item_text(&app, quit_item, quit); + { + let guard = TRAY.lock().unwrap(); + if let Some(text) = guard.as_ref().and_then(render_now_playing) { + apply_item_text(&app, now_item, text); + } + } +} + +/// 同步托盘「正在播放」项为当前曲目名(title 为空时恢复「正在播放」)。 +/// 由后台 ticker 在曲目切换时调用;文案模板来自前端 i18n 资源 +pub fn update_now_playing(title: &str) { + let mut guard = TRAY.lock().unwrap(); + let Some(state) = guard.as_mut() else { + return; + }; + state.current_title = title.to_string(); + let Some(text) = render_now_playing(state) else { + // 文案尚未下发(set_tray_texts 未到),先记住标题,下发后自动重绘 + return; + }; + log::info!(target: "tray", "update_now_playing: title={title:?}"); + let app = state.app.clone(); + let item = state.handles.now_playing.clone(); + drop(guard); + apply_item_text(&app, item, text); +} + +#[cfg(test)] +mod tests { + use super::format_now_playing; + + #[test] + fn empty_title_uses_bare_label() { + assert_eq!( + format_now_playing("", "正在播放", "正在播放:{title}"), + "正在播放" + ); + assert_eq!( + format_now_playing(" ", "Now Playing", "Now Playing: {title}"), + "Now Playing" + ); + } + + #[test] + fn title_is_embedded_in_template() { + assert_eq!( + format_now_playing("风又音理", "正在播放", "正在播放:{title}"), + "正在播放:风又音理" + ); + assert_eq!( + format_now_playing("Song", "Now Playing", "Now Playing: {title}"), + "Now Playing: Song" + ); + } + + #[test] + fn long_title_is_truncated_to_40_chars() { + let long = "很长的歌曲标题,超过四十个字的时候应该被截断并加省略号,多出来的这些字不应该显示出来"; + let rendered = format_now_playing(long, "正在播放", "正在播放:{title}"); + let tail = rendered.trim_start_matches("正在播放:"); + assert_eq!(tail.chars().count(), 41); // 40 字 + 省略号 + assert!(tail.ends_with('…')); + } + + #[test] + fn template_without_placeholder_keeps_text() { + assert_eq!( + format_now_playing("Song", "Now Playing", "Playing {title} now"), + "Playing Song now" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c4dd7ac..56b3fd6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,11 +5,15 @@ use neri_player_desktop::audio::media_session::{MediaAction, MediaSessionControl use neri_player_desktop::auth; use neri_player_desktop::commands::{ auth_cmd, download_cmd, image_cmd, library_cmd, listen_together_cmd, lyrics_cmd, player_cmd, - recommend_cmd, search_cmd, settings_cmd, stats_cmd, storage_cmd, sync_cmd, debug_cmd,}; + recommend_cmd, search_cmd, settings_cmd, stats_cmd, storage_cmd, sync_cmd, tray_cmd, + debug_cmd,}; use neri_player_desktop::state::AppState; -use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; use std::time::Duration; -use tauri::{Emitter, Manager}; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{Emitter, Manager, WindowEvent}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PlaybackFinishState { @@ -61,6 +65,10 @@ fn main() { // 是否写文件与日志级别(此时尚无 app handle) let log_cfg = neri_player_desktop::logging::load_bootstrap_config(); + // 托盘「退出」标记:窗口关闭只隐藏到托盘,仅托盘退出允许结束进程 + let quitting = Arc::new(AtomicBool::new(false)); + let quitting_tray = quitting.clone(); + tauri::Builder::default() // 单实例保护必须最先注册:双实例会共享 deviceId 与 causal counter, // 重复发号 token 造成跨设备同步静默数据损坏,playlists/stats 等 @@ -81,7 +89,7 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(AppState::new()) - .setup(|app| { + .setup(move |app| { let handle = app.handle().clone(); // macOS 使用原生红绿灯(Overlay 标题栏);Windows/Linux 移除原生装饰, @@ -122,6 +130,101 @@ fn main() { } } + // 系统托盘:窗口关闭后隐藏到托盘继续播放;左键单击恢复主窗口 + // 播放控制复用 media:* 事件(与系统媒体键同一套前端处理链路) + // 「正在播放」菜单项由后台 ticker 在曲目切换时更新标题 + let tray_now_playing_item = MenuItem::with_id( + app, "tray-now-playing", "正在播放", true, None::<&str>, + )?; + { + let quit_flag = quitting_tray.clone(); + fn show_main_window(app: &tauri::AppHandle) { + if let Some(win) = app.get_webview_window("main") { + let _ = win.unminimize(); + let _ = win.show(); + let _ = win.set_focus(); + } + } + + let tray_menu = Menu::new(app)?; + let tray_prev_item = + MenuItem::with_id(app, "tray-prev", "上一首", true, None::<&str>)?; + let tray_toggle_item = + MenuItem::with_id(app, "tray-toggle", "暂停/播放", true, None::<&str>)?; + let tray_next_item = + MenuItem::with_id(app, "tray-next", "下一首", true, None::<&str>)?; + let tray_home_item = + MenuItem::with_id(app, "tray-home", "打开主页面", true, None::<&str>)?; + let tray_quit_item = + MenuItem::with_id(app, "tray-quit", "退出", true, None::<&str>)?; + tray_menu.append(&tray_prev_item)?; + tray_menu.append(&tray_toggle_item)?; + tray_menu.append(&tray_next_item)?; + tray_menu.append(&PredefinedMenuItem::separator(app)?)?; + tray_menu.append(&tray_now_playing_item)?; + tray_menu.append(&tray_home_item)?; + tray_menu.append(&PredefinedMenuItem::separator(app)?)?; + tray_menu.append(&tray_quit_item)?; + + // 注册句柄供 set_tray_texts / update_now_playing 使用(多语言 + 曲目名) + tray_cmd::register_tray_handles(app.handle(), tray_cmd::TrayMenuHandles { + prev: tray_prev_item, + toggle: tray_toggle_item, + next: tray_next_item, + now_playing: tray_now_playing_item, + home: tray_home_item, + quit: tray_quit_item, + }, + ); + + let mut builder = TrayIconBuilder::with_id("main-tray") + .menu(&tray_menu) + .tooltip("NeriPlayer") + // Windows 上左键直接恢复窗口、右键弹菜单 + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id().as_ref() { + "tray-prev" => { + let _ = app.emit("media:previous", ()); + } + "tray-toggle" => { + let _ = app.emit("media:toggle", ()); + } + "tray-next" => { + let _ = app.emit("media:next", ()); + } + "tray-now-playing" => { + show_main_window(app); + let _ = app.emit("tray:open-now-playing", ()); + } + "tray-home" => { + show_main_window(app); + let _ = app.emit("tray:open-home", ()); + } + "tray-quit" => { + quit_flag.store(true, Ordering::Relaxed); + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(move |tray, event| { + // 左键单击恢复主窗口 + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_main_window(tray.app_handle()); + } + }); + + // 托盘图标默认取应用的 default_window_icon(bundle.icon 嵌入) + if let Some(icon) = app.default_window_icon() { + builder = builder.icon(icon.clone()); + } + builder.build(app)?; + } + // 恢复持久化的登录 Cookie { let state = handle.state::(); @@ -285,16 +388,15 @@ fn main() { } } - // 媒体会话同步(每 1s = 每 5 个 tick) - if let Some(ref ms) = media_session { - media_update_counter += 1; - - // 元数据来源改为前端镜像: PlayQueue 从不被前端填充, 读它使 - // SMTC/MPRIS 永远拿不到曲目信息(PB-01) - let current_meta = state.media_metadata.lock().clone(); - if let Some(meta) = current_meta { - if !meta.id.is_empty() && meta.id != last_media_track_id { - last_media_track_id = meta.id.clone(); + // 媒体会话同步(每 1s = 每 5 个 tick)与托盘曲目名同步。 + // 托盘更新不依赖 media_session:MPRIS/SMTC 不可用时曲目名也要更新 + let current_meta = state.media_metadata.lock().clone(); + if let Some(meta) = current_meta { + if !meta.id.is_empty() && meta.id != last_media_track_id { + last_media_track_id = meta.id.clone(); + // 托盘菜单同步当前曲目名(仅在曲目切换时更新) + tray_cmd::update_now_playing(&meta.title); + if let Some(ref ms) = media_session { ms.update_metadata( &meta.title, &meta.artist, @@ -304,7 +406,13 @@ fn main() { ); } } + } else if !last_media_track_id.is_empty() { + last_media_track_id.clear(); + tray_cmd::update_now_playing(""); + } + if let Some(ref ms) = media_session { + media_update_counter += 1; if media_update_counter >= 5 { media_update_counter = 0; ms.update_playback(snap_playing, snap_pos); @@ -448,6 +556,7 @@ fn main() { settings_cmd::save_file_bytes, settings_cmd::set_bypass_proxy, settings_cmd::get_build_info, + settings_cmd::get_system_accent_color, settings_cmd::probe_platform_connectivity, debug_cmd::get_recent_logs, debug_cmd::export_debug_report, @@ -530,6 +639,7 @@ fn main() { stats_cmd::clear_playback_stats, stats_cmd::remove_playback_stats, stats_cmd::playback_stats_identity_key, + tray_cmd::set_tray_texts, ]); move |invoke: tauri::ipc::Invoke| { let label = invoke.message.webview().label().to_string(); @@ -549,12 +659,42 @@ fn main() { }) .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|app_handle, event| { - // 退出前 flush 一次轮转 Cookie:60s 定时器之外,退出前最后一窗口的 - // Set-Cookie 轮换令牌若不落盘,下次启动会重放旧令牌导致偶发掉登录(AU-05) - if let tauri::RunEvent::ExitRequested { .. } = event { - let state = app_handle.state::(); - auth_cmd::persist_rotated_cookies(app_handle, state.inner()); + .run({ + let quitting = quitting.clone(); + move |app_handle, event| match event { + tauri::RunEvent::ExitRequested { api, .. } => { + if !quitting.load(Ordering::Relaxed) && cfg!(not(target_os = "macos")) { + // 窗口关闭只是隐藏到托盘;仅托盘「退出」允许结束进程。 + // macOS 保留原生 Cmd+Q 语义(关窗本就不退出) + api.prevent_exit(); + for win in app_handle.webview_windows().values() { + let _ = win.hide(); + } + } else { + // 退出前 flush 一次轮转 Cookie:60s 定时器之外,退出前最后一窗口的 + // Set-Cookie 轮换令牌若不落盘,下次启动会重放旧令牌导致偶发掉登录(AU-05) + let state = app_handle.state::(); + auth_cmd::persist_rotated_cookies(app_handle, state.inner()); + } + } + tauri::RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } => { + // 关闭 = 隐藏到托盘。prevent_close 在 GTK 层真正取消 + // delete-event,不会重发;前端若在 JS 侧 hide()/close() + // 回退会与平台关闭状态互扰,在 WebKitGTK 下造成 + // CloseRequested 死循环(flush 刷屏)并拖垮 GPU 上下文。 + // 已隐藏时忽略重复请求,避免重发循环 + api.prevent_close(); + if let Some(win) = app_handle.get_webview_window(&label) { + if win.is_visible().unwrap_or(false) { + let _ = win.hide(); + } + } + } + _ => {} } }); } diff --git a/src-tauri/src/settings/store.rs b/src-tauri/src/settings/store.rs index cdd0aff..b8e4854 100644 --- a/src-tauri/src/settings/store.rs +++ b/src-tauri/src/settings/store.rs @@ -52,6 +52,10 @@ pub struct AppSettings { pub qq_music_offset: i32, pub cover_style: String, pub advanced_lyrics: bool, + /// 取色方式:system=跟随系统取色 / default=默认主题色 / cover=跟随封面动态取色 + pub color_mode: String, + /// 旧版字段(封面取色开关),仅用于迁移,导出配置时不序列化 + #[serde(default, skip_serializing)] pub dynamic_color: bool, pub dynamic_background: bool, pub audio_reactive: bool, @@ -118,6 +122,7 @@ impl Default for AppSettings { qq_music_offset: 500, cover_style: "card".into(), advanced_lyrics: true, + color_mode: "default".into(), dynamic_color: false, dynamic_background: true, audio_reactive: true, @@ -231,6 +236,18 @@ impl AppSettings { "info", ); + // 取色方式:旧版 dynamic_color(封面取色开关)迁移到 color_mode; + // Android 导入语义不同(dynamic_color=true=跟随系统),由导入路径直接写 color_mode + if self.dynamic_color && (self.color_mode.is_empty() || self.color_mode == "default") { + self.color_mode = "cover".into(); + } + self.color_mode = normalize_choice( + &self.color_mode, + &["system", "default", "cover"], + "default", + ); + self.dynamic_color = false; + self.background_image_uri = self.background_image_uri.trim().into(); self.download_name_template = non_empty_or_default( &self.download_name_template, diff --git a/src/App.vue b/src/App.vue index e320ac7..0532385 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,7 +22,7 @@ import { setLocale } from '@/i18n' import { applyTheme } from '@/utils/theme' import { applyThemeColor } from '@/utils/themeColor' import { getTrackCoverUrl } from '@/utils/trackCover' -import { applyDynamicColorFromCover, clearDynamicColor } from '@/utils/colorExtractor' +import { applyDynamicColorFromCover, applyDynamicColorFromSeed, clearDynamicColor, resolveSystemAccentSeed } from '@/utils/colorExtractor' import { hasVisiblePlaybackSession } from '@/modules/playback/playbackRequest' type CoverSnapshot = { @@ -190,6 +190,8 @@ let historyBatchedTimer: ReturnType | null = null let periodicSyncTimer: ReturnType | null = null let unlistenPlaylistChanged: UnlistenFn | null = null let unlistenCloseRequested: UnlistenFn | null = null +let unlistenTrayNowPlaying: UnlistenFn | null = null +let unlistenTrayHome: UnlistenFn | null = null function handleBeforeUnload() { player.flushPlayerState() @@ -197,26 +199,21 @@ function handleBeforeUnload() { void usePlaybackStatsStore().flushFinal() } -// 关窗流程标志位:flush 完成后 destroy,二次进入直接放行避免死循环 +// 关窗流程标志位:flush 完成后放行;窗口隐藏/关闭拦截全部由 Rust 侧完成 let closeFlushDone = false async function handleCloseRequested(event: { preventDefault: () => void }) { - if (closeFlushDone) return event.preventDefault() + if (closeFlushDone) return closeFlushDone = true try { - // 同步保存播放器状态 + 等待统计落盘后再真正关窗 + // 同步保存播放器状态 + 等待统计落盘(隐藏后播放继续,下次关闭不再有卸载事件) player.flushPlayerState() await usePlaybackStatsStore().flushFinal() } catch { - // 落盘失败不阻塞退出 - } - try { - await getCurrentWindow().destroy() - } catch { - // destroy 不可用时退回 close(此时标志位已放行) - void getCurrentWindow().close().catch(() => {}) + // 落盘失败不阻塞 } + closeFlushDone = false } function scheduleDebouncedSync() { @@ -290,22 +287,36 @@ function resolveDynamicIsDark(): boolean { return window.matchMedia('(prefers-color-scheme: dark)').matches } -// 开关、深浅色或封面变化时重算;关闭或无封面则还原预设主题色 +// 取色方式、深浅色或封面变化时重算;default 或无封面则还原预设主题色 watch( () => [ - settingsStore.dynamicColor, + settingsStore.colorMode, settingsStore.darkMode, player.hasPlaybackSession ? getTrackCoverUrl(player.currentTrack) : '', ] as const, - ([enabled, , cover]) => { + async ([mode, , cover]) => { // 圆形扩散中由 theme 路径同步重算,避免圆外提前换色 if (document.documentElement.classList.contains('theme-ripple-active')) return const dark = resolveDynamicIsDark() - if (!enabled || !cover) { + if (mode === 'cover') { + if (cover) { + void applyDynamicColorFromCover(cover as string, dark) + return + } clearDynamicColor(dark) return } - void applyDynamicColorFromCover(cover as string, dark) + if (mode === 'system') { + const seed = await resolveSystemAccentSeed() + // 异步探测期间取色方式可能已切换,以最新状态为准 + if (settingsStore.colorMode !== 'system') return + if (seed) { + applyDynamicColorFromSeed(seed, dark) + return + } + // 引擎不支持解析系统强调色时回退默认取色 + } + clearDynamicColor(dark) }, ) @@ -375,9 +386,12 @@ onMounted(async () => { applyTheme(settingsStore.darkMode, false) applyThemeColor(settingsStore.themeColor, undefined, false) // 首屏在预设主题之后应用动态取色,避免被 applyThemeColor 覆盖 - if (settingsStore.dynamicColor) { + if (settingsStore.colorMode === 'cover') { const cover = player.hasPlaybackSession ? getTrackCoverUrl(player.currentTrack) : '' if (cover) void applyDynamicColorFromCover(cover, resolveDynamicIsDark()) + } else if (settingsStore.colorMode === 'system') { + const seed = await resolveSystemAccentSeed() + if (seed) applyDynamicColorFromSeed(seed, resolveDynamicIsDark()) } setLocale(settingsStore.locale, false) await player.applyPersistedSettings() @@ -409,6 +423,14 @@ onMounted(async () => { scheduleDebouncedSync() }) + // 托盘菜单:正在播放 / 打开主页面(窗口可能处于隐藏状态,先由后端 show) + unlistenTrayNowPlaying = await listen('tray:open-now-playing', () => { + if (player.hasPlaybackSession) void openNowPlaying() + }) + unlistenTrayHome = await listen('tray:open-home', () => { + void router.push({ name: 'home' }) + }) + // 监听前端播放历史变更事件,触发历史自动同步 window.addEventListener(HISTORY_CHANGED_EVENT, scheduleHistorySync as EventListener) }) @@ -428,6 +450,8 @@ onUnmounted(() => { window.removeEventListener(HISTORY_CHANGED_EVENT, scheduleHistorySync as EventListener) if (unlistenPlaylistChanged) unlistenPlaylistChanged() if (unlistenCloseRequested) unlistenCloseRequested() + if (unlistenTrayNowPlaying) unlistenTrayNowPlaying() + if (unlistenTrayHome) unlistenTrayHome() }) diff --git a/src/components/HyperBackground.vue b/src/components/HyperBackground.vue index d34cbfe..b469432 100644 --- a/src/components/HyperBackground.vue +++ b/src/components/HyperBackground.vue @@ -35,6 +35,55 @@ let animFrame = 0 let startTime = 0 let lastFrameMs = 0 +// —— 自适应渲染质量 —— +// WebKitGTK 的 WebGL 在不同驱动下性能差异巨大:NVIDIA 混合显卡 EGL 路径、 +// 软件渲染(llvmpipe)时全屏 2x 分辨率逐帧重绘会严重卡顿,且 CPU/GPU 占用 +// 看着不高(瓶颈在 WebKit 内部光栅/合成路径)。按实测帧耗时动态调整内部 +// 渲染分辨率与帧率:GPU 富余时维持满质量,吃紧时逐级降档,恢复后再升回。 +const QUALITY_ADJUST_INTERVAL_MS = 1000 +const QUALITY_MIN_SCALE = 0.5 +const QUALITY_MAX_SCALE = 1.0 +let qualityScale = 1.0 +let qualityFps = 60 +let lastQualityCheckAt = 0 +let lastRenderedAt = 0 +let qualityDowns = 0 + +function rendererIsSoftware(renderer: string): boolean { + return /llvmpipe|softpipe|swiftshader|software/i.test(renderer) +} + +function adjustQuality(nowMs: number): void { + if (nowMs - lastQualityCheckAt < QUALITY_ADJUST_INTERVAL_MS) return + lastQualityCheckAt = nowMs + // 上一帧实际耗时(含 vsync 等待;60Hz 下健康值约 16-17ms) + const frameCost = nowMs - lastRenderedAt + if (frameCost > 33) { + // 低于 ~30fps:先降内部分辨率,再降帧率 + if (qualityScale > QUALITY_MIN_SCALE) { + qualityScale = Math.max(QUALITY_MIN_SCALE, qualityScale - 0.25) + } else if (qualityFps > 24) { + qualityFps = Math.max(24, qualityFps - 12) + } + qualityDowns += 1 + if (qualityDowns <= 3) { + log.warn('WebGL 帧耗时偏高,降档渲染:', { + frameCostMs: Math.round(frameCost), + qualityScale, + qualityFps, + }) + } + } else if (frameCost < 20) { + // 帧率余裕:恢复档位 + if (qualityScale < QUALITY_MAX_SCALE) { + qualityScale = Math.min(QUALITY_MAX_SCALE, qualityScale + 0.25) + } else if (qualityFps < 60) { + qualityFps = Math.min(60, qualityFps + 12) + } + qualityDowns = 0 + } +} + // 音乐律动第二级非对称平滑(对齐 Android HyperBackground 帧循环) // Rust analyzer.rs 已做第一级帧率无关衰减,这里补一层 attack/release, // 并统一换算为与帧率无关的系数,避免 120Hz(ProMotion)/30fps 上手感不同。 @@ -125,6 +174,16 @@ function initGL() { gl = canvas.value.getContext('webgl', { alpha: true, antialias: false, premultipliedAlpha: false }) if (!gl) { log.error('WebGL not supported'); return } + // 诊断:记录实际 GL 渲染器,软件渲染(llvmpipe 等)直接预降档 + const glRenderer = String(gl.getParameter(gl.RENDERER) ?? '') + const glVendor = String(gl.getParameter(gl.VENDOR) ?? '') + log.info('WebGL context:', { vendor: glVendor, renderer: glRenderer }) + if (rendererIsSoftware(glRenderer)) { + log.warn('WebGL 为软件渲染,直接降到低档以保流畅:', { renderer: glRenderer }) + qualityScale = QUALITY_MIN_SCALE + qualityFps = 24 + } + const vs = compileShader(gl, hyperBackgroundVertexShader, gl.VERTEX_SHADER) const fs = compileShader(gl, hyperBackgroundFragmentShader, gl.FRAGMENT_SHADER) if (!vs || !fs) return @@ -189,16 +248,25 @@ function initGL() { function render() { if (!gl || !program) return + const nowMs = performance.now() + + // 帧率档位低于满速时按目标帧间隔跳过绘制(保留 rAF 保证恢复及时) + if (qualityFps < 60 && nowMs - lastRenderedAt < 1000 / qualityFps) { + animFrame = requestAnimationFrame(render) + return + } + adjustQuality(nowMs) + const c = canvas.value! // mac/GPU 富余场景允许到 2.0,保证 Retina/HiDPI 清晰度;其余仍限制以省 GPU const dprCap = window.devicePixelRatio >= 2 ? 2.0 : 1.5 - const dpr = Math.min(window.devicePixelRatio || 1, dprCap) - const w = c.clientWidth * dpr - const h = c.clientHeight * dpr + // 内部渲染分辨率 = 设备缩放 × 自适应档位(低档时由 CSS 拉伸,背景本就模糊无感知) + const dpr = Math.min(window.devicePixelRatio || 1, dprCap) * qualityScale + const w = Math.max(1, Math.round(c.clientWidth * dpr)) + const h = Math.max(1, Math.round(c.clientHeight * dpr)) if (c.width !== w || c.height !== h) { c.width = w; c.height = h } gl.viewport(0, 0, w, h) - const nowMs = performance.now() const dt = Math.min((nowMs - lastFrameMs) / 1000, 0.1) // 秒,钳制避免卡顿后跳变 lastFrameMs = nowMs @@ -254,6 +322,7 @@ function render() { gl.uniform1f(uSaturateOffset, smoothSaturateOffset) gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) + lastRenderedAt = nowMs animFrame = requestAnimationFrame(render) } diff --git a/src/components/LyricsView.vue b/src/components/LyricsView.vue index 6dfe655..4a8673f 100644 --- a/src/components/LyricsView.vue +++ b/src/components/LyricsView.vue @@ -47,6 +47,8 @@ let layoutFrameId = 0 let layoutSyncToken = 0 let lastFrameAt = 0 let lastSyncedTime = Number.NaN +let lastFeedAt = 0 +let settleTimer: ReturnType | null = null let resizeObserver: ResizeObserver | null = null let lastHostWidth = 0 let lastHostHeight = 0 @@ -57,6 +59,12 @@ const AMLL_WORD_FADE_WIDTH = 0.5 const LAYOUT_SETTLE_PASSES = 4 const LAYOUT_SETTLE_MAX_PASSES = 8 const SIZE_EPSILON = 0.5 +// 时间喂入节流:后端插值时钟在 60/120Hz 显示上每帧变化,逐帧 setCurrentTime +// 会让 AMLL 每帧重算整棵歌词状态树;30ms 粒度下行切换/逐字误差不可感知, +// 弹簧与 WAAPI 词遮罩动画各自独立走时钟,不受喂入频率影响 +const TIME_FEED_INTERVAL_MS = 30 +// 暂停态下 seek/布局后弹簧需要短暂逐帧驱动收敛,收敛后停表省掉每帧样式写入 +const PAUSE_SETTLE_MS = 500 interface PlayerRubyWord { startMs: number @@ -278,15 +286,24 @@ function syncCurrentTime(forceSeek = false): void { if (!lyricPlayer) return const time = Math.max(0, Math.round(amllTimeMs.value)) const drift = Math.abs(time - lastSyncedTime) - if (!forceSeek && drift < 1) return // 只有真正的跳转(>500ms)才强制 seek。插值时钟被后端位置事件小幅 // 回拉是常态(缓冲、事件节流都会造成 100ms 级摆动),80ms 就强跳的话 // 每次回拉歌词都猛抖一下——「一抖一抖」就是它。500ms 以内直接喂时间, // AMLL 按连续播放自行平滑,行切换粒度是秒级,不会因此卡错行。 if (!forceSeek && drift >= 500) forceSeek = true + if (forceSeek) { + lastFeedAt = performance.now() + } else { + const now = performance.now() + if (now - lastFeedAt < TIME_FEED_INTERVAL_MS) return + if (drift < 1) return + lastFeedAt = now + } + lyricPlayer.setCurrentTime(time, forceSeek) lastSyncedTime = time + if (forceSeek) requestSettleLoop() } function syncPlayState(): void { @@ -418,6 +435,50 @@ function stopFrameLoop(): void { rafId = 0 } +/// 帧循环按需启停:AMLL 的 update() 每帧会给所有已挂载歌词行写 +/// transform/opacity/filter 等样式,暂停时歌词动画已冻结,继续逐帧驱动 +/// 只会徒增样式重算与重绘(WebKitGTK 尤甚)。暂停态下 seek/布局等 +/// 需要弹簧收敛的场景由 requestSettleLoop 短暂续跑。 +function syncFrameLoop(): void { + const shouldRun = props.isPlaying || props.previewTimeMs != null + if (shouldRun) startFrameLoop() + else stopFrameLoop() +} + +function requestSettleLoop(): void { + if (props.isPlaying || props.previewTimeMs != null) return + startFrameLoop() + if (settleTimer) clearTimeout(settleTimer) + settleTimer = setTimeout(() => { + settleTimer = null + if (!props.isPlaying && props.previewTimeMs == null) stopFrameLoop() + }, PAUSE_SETTLE_MS) +} + +function clearSettleTimer(): void { + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } +} + +let stopManualScrollWatcher: (() => void) | null = null + +/// 暂停态下手动滚动歌词:AMLL 的滚动由弹簧驱动,弹簧只在 update() 里推进, +/// 帧循环停表时滚轮/触摸拖动不会产生视觉位移。滚动事件持续刷新续跑窗口, +/// 停止滚动 500ms 后自动停表 +function startManualScrollWatcher(): void { + if (!hostRef.value || stopManualScrollWatcher) return + const onScroll = () => requestSettleLoop() + hostRef.value.addEventListener('wheel', onScroll, { passive: true }) + hostRef.value.addEventListener('touchmove', onScroll, { passive: true }) + stopManualScrollWatcher = () => { + hostRef.value?.removeEventListener('wheel', onScroll) + hostRef.value?.removeEventListener('touchmove', onScroll) + stopManualScrollWatcher = null + } +} + function cancelLayoutSync(): void { if (layoutFrameId) cancelAnimationFrame(layoutFrameId) layoutFrameId = 0 @@ -481,6 +542,7 @@ function forceLayoutAtCurrentTime(): boolean { } lastSyncedTime = time + requestSettleLoop() return hasPlayerSize } @@ -578,11 +640,14 @@ onMounted(() => { startResizeObserver() reloadLyrics() - startFrameLoop() + startManualScrollWatcher() + syncFrameLoop() }) }) onUnmounted(() => { + clearSettleTimer() + stopManualScrollWatcher?.() stopFrameLoop() cancelLayoutSync() stopResizeObserver() @@ -616,6 +681,13 @@ watch(() => settings.lyricFontScale, () => { watch(() => props.isPlaying, () => { syncPlayState() + clearSettleTimer() + syncFrameLoop() +}) + +watch(() => props.previewTimeMs, () => { + if (props.isPlaying) syncFrameLoop() + else requestSettleLoop() }) watch(amllTimeMs, (time, oldTime) => { diff --git a/src/components/MiniPlayer.vue b/src/components/MiniPlayer.vue index e41f178..edef404 100644 --- a/src/components/MiniPlayer.vue +++ b/src/components/MiniPlayer.vue @@ -643,15 +643,18 @@ defineExpose({ } .mp-volume-slider { - writing-mode: vertical-lr; + // 竖排滑条不能用 writing-mode(WebKitGTK 圆点错位且拖不动), + // 用原生横向滑条整体旋转:几何/命中计算保持原生,视觉为竖向。 + // value = volume:rotate(-90deg) 使 min 在下,即静音在下、最大音量在上 appearance: none; - width: 4px; - height: 100px; + width: 100px; + height: 4px; + transform: rotate(-90deg); + margin: 48px 0; background: var(--md-surface-container-highest); border-radius: 2px; outline: none; cursor: pointer; - direction: rtl; &::-webkit-slider-thumb { appearance: none; @@ -660,7 +663,7 @@ defineExpose({ border-radius: 50%; background: var(--md-primary); cursor: pointer; - box-shadow: 0 1px 4px rgba(0,0,0,0.2); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); } } diff --git a/src/components/NowPlaying.vue b/src/components/NowPlaying.vue index 2d17d12..1e31fe1 100644 --- a/src/components/NowPlaying.vue +++ b/src/components/NowPlaying.vue @@ -28,7 +28,7 @@ import { mergeParsedLyricsWithTranslations, } from '@/modules/lyrics/lyricsFormat' import { offsetBucketForSource } from '@/modules/lyrics/lyricOffset' -import { isEditableTarget } from '@/modules/shortcuts/platform' +import { isEditableTarget, isMacPlatform } from '@/modules/shortcuts/platform' import { persistTrackSyncPayload, withUpdatedCustomInfoPayload } from '@/modules/lyrics/syncTrackPayload' import { useLyricOffsetStore } from '@/stores/lyricOffset' import HyperBackground from './HyperBackground.vue' @@ -1907,6 +1907,7 @@ const sliderActiveColor = computed(() => { { 'np-shell--track-switching': isTrackSwitchAnimating, 'np-shell--beat-active': isVisualBeatActive, + 'np--rounded-window': isMacPlatform, }, ]" :style="dynamicColorVars" @@ -3028,13 +3029,19 @@ const sliderActiveColor = computed(() => { // 确保完全不透明 isolation: isolate; overflow: hidden; - /* 与窗体圆角一致,避免全屏层直角顶出 OS 圆角 */ - border-radius: var(--radius-lg); + // Windows/Linux 窗口为方形,圆角会让四角透出下层页面; + // macOS 原生窗口自带圆角,仅在 macOS 保持圆角(np--rounded-window) + border-radius: 0; user-select: none; -webkit-user-select: none; transition: transform 460ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease; } +.now-playing.np--rounded-window { + /* 与 macOS 窗体圆角一致,避免全屏层直角顶出 OS 圆角 */ + border-radius: var(--radius-lg); +} + .np-empty-state { position: relative; z-index: 3; @@ -4297,15 +4304,18 @@ const sliderActiveColor = computed(() => { } .volume-slider { - writing-mode: vertical-lr; + // 竖排滑条不能用 writing-mode(WebKitGTK 圆点错位且拖不动), + // 用原生横向滑条整体旋转:几何/命中计算保持原生,视觉为竖向。 + // value = 1 - volume:rotate(90deg) 使 min 在上,即音量满格在上 appearance: none; - width: 4px; - height: 100px; - background: rgba(255,255,255,0.15); + width: 100px; + height: 4px; + transform: rotate(90deg); + margin: 48px 0; + background: rgba(255, 255, 255, 0.15); border-radius: 2px; outline: none; cursor: pointer; - accent-color: var(--np-primary, #fff); &::-webkit-slider-thumb { appearance: none; @@ -4313,7 +4323,7 @@ const sliderActiveColor = computed(() => { height: 14px; border-radius: 50%; background: var(--np-primary-container, white); - box-shadow: 0 1px 4px rgba(0,0,0,0.3); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); cursor: pointer; } } diff --git a/src/components/QueuePanel.vue b/src/components/QueuePanel.vue index 2a6d46c..5ea97e1 100644 --- a/src/components/QueuePanel.vue +++ b/src/components/QueuePanel.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { usePlayerStore } from '@/stores/player' import { useI18n } from 'vue-i18n' +import { useIncrementalList } from '@/composables/useIncrementalList' import BilibiliCoverImage from './BilibiliCoverImage.vue' import ContextMenu from '@/components/ui/ContextMenu.vue' import { @@ -26,6 +27,11 @@ function handleEscKeydown(event: KeyboardEvent) { } onMounted(() => document.addEventListener('keydown', handleEscKeydown)) onUnmounted(() => document.removeEventListener('keydown', handleEscKeydown)) +// 大队列分块渲染(队列可达上千行,WebKitGTK 一次性渲染会卡顿) +const { visibleItems: visibleQueue, onScroll: onQueueScroll } = useIncrementalList( + () => player.queue, +) + const queueContextMenuOpen = ref(false) const queueContextMenuPosition = ref({ x: 0, y: 0 }) const queueContextMenuIndex = ref(-1) @@ -127,9 +133,9 @@ function handleQueueContextMenuClick(item: ContextMenuActionItem) {

{{ t('player.no_queue') }}

-
+
(getSource: () => readonly T[]) { + const source = computed(() => getSource()) + const renderCount = ref(RENDER_CHUNK) + const visibleItems = computed(() => source.value.slice(0, renderCount.value)) + const hasMore = computed(() => renderCount.value < source.value.length) + + // 内容整体变化(搜索过滤/替换)时重置窗口 + watch(source, (list) => { + renderCount.value = Math.min(RENDER_CHUNK, list.length) + }) + // 行数收缩(删除/清空)时把窗口拉回来,避免窗口悬空 + watch( + () => source.value.length, + (len) => { + if (len < renderCount.value) { + renderCount.value = Math.min(RENDER_CHUNK, len) + } + }, + ) + + function expand(extra = RENDER_CHUNK) { + if (!hasMore.value) return + renderCount.value = Math.min(source.value.length, renderCount.value + extra) + } + + /** 绑定到滚动容器:接近底部时自动扩容 */ + function onScroll(e: Event) { + const el = e.currentTarget as HTMLElement | null + if (!el || !hasMore.value) return + if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_PRELOAD_PX) { + expand() + } + } + + /** 确保真实索引已渲染(定位当前曲目用),返回该行是否可达 */ + function ensureIndex(index: number): boolean { + if (index < 0 || index >= source.value.length) return false + if (index >= renderCount.value) { + renderCount.value = Math.min(source.value.length, index + LOCATE_BUFFER) + } + return true + } + + return { visibleItems, hasMore, expand, onScroll, ensureIndex } +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 06a74e1..88bc0c9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -416,8 +416,11 @@ "color_brown": "Brown", "color_orange": "Orange", "color_green": "Green", - "dynamic_color": "Dynamic Color", - "dynamic_color_desc": "Automatically tint the interface with the cover's theme color", + "color_mode": "Color Mode", + "color_mode_desc": "Choose the source of the UI accent color", + "color_mode_system": "Follow system color", + "color_mode_default": "Default color", + "color_mode_cover": "Dynamic color from cover", "personalization": "Personalization", "nav_personalization_desc": "Adjust visuals and player layout", "motion": "Motion Settings", @@ -870,5 +873,14 @@ "copy_line": "Copy this line", "copy_all": "Copy all lyrics", "seek_here": "Jump to this line" + }, + "tray": { + "prev": "Previous", + "toggle": "Play/Pause", + "next": "Next", + "now_playing": "Now Playing", + "now_playing_title": "Now Playing: {title}", + "open_home": "Open Main Page", + "quit": "Quit" } } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 5851acb..431fa90 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -3,6 +3,16 @@ import zhCN from './zh-CN.json' import zhTW from './zh-TW.json' import en from './en.json' import ja from './ja.json' +import { createLogger } from '@/utils/logger' + +const traySyncLog = createLogger('tray-sync') + +// WebKitGTK 的 View Transition 实现不稳定:语言切换会整树重渲染,在 Linux +// 上曾导致 WebKit 主循环内 SEGV 崩溃(GTK 主循环 -> WebKit 回调 -> JSC)。 +// Linux 走平滑降级路径(直接切 locale),Windows/macOS 保留过渡动画 +import { detectPlatform } from '@/modules/shortcuts/platform' + +export const VIEW_TRANSITION_SUPPORTED = detectPlatform() !== 'linux' export const SUPPORTED_LOCALES = [ { code: 'zh-CN', label: '简体中文' }, @@ -42,11 +52,27 @@ export function setLocale(locale: string, persist = true) { ;(i18n.global.locale as any).value = locale if (persist) localStorage.setItem('locale', locale) document.documentElement.lang = locale + // 同步托盘菜单文案:从中央 i18n 资源取当前语言的已翻译文案下发(浏览器开发模式静默失败)。 + // 注意模板里的 {title} 会被 t() 当作插值占位符吞掉,必须用 getLocaleMessage 取原始文案 + const t = i18n.global.t + const rawTray = (i18n.global.getLocaleMessage(locale) as { tray?: Record })?.tray + const trayText = (key: string) => rawTray?.[key] ?? t(`tray.${key}`) + import('@tauri-apps/api/core') + .then(({ invoke }) => void invoke('set_tray_texts', { + prev: trayText('prev'), + toggle: trayText('toggle'), + next: trayText('next'), + nowPlaying: trayText('now_playing'), + nowPlayingTitle: trayText('now_playing_title'), + openHome: trayText('open_home'), + quit: trayText('quit'), + }).catch(() => {})) + .catch(() => {}) } /** 带 View Transition 动画的语言切换 */ export async function setLocaleWithTransition(locale: string, x?: number, y?: number, persist = true) { - if (!(document as any).startViewTransition || !x || !y) { + if (!(document as any).startViewTransition || !x || !y || !VIEW_TRANSITION_SUPPORTED) { setLocale(locale, persist) return } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index e9758df..c02d539 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -416,8 +416,11 @@ "color_brown": "茶色", "color_orange": "オレンジ", "color_green": "緑", - "dynamic_color": "ダイナミックカラー", - "dynamic_color_desc": "カバーのテーマカラーに合わせて配色を自動調整", + "color_mode": "カラーモード", + "color_mode_desc": "UIアクセントカラーの出所を選択", + "color_mode_system": "システムカラーに追従", + "color_mode_default": "デフォルトカラー", + "color_mode_cover": "カバーから動的取得", "personalization": "カスタマイズ", "nav_personalization_desc": "外観と再生画面を調整", "motion": "モーション設定", @@ -870,5 +873,14 @@ "copy_line": "この行をコピー", "copy_all": "歌詞をすべてコピー", "seek_here": "この行へジャンプ" + }, + "tray": { + "prev": "前へ", + "toggle": "再生/一時停止", + "next": "次へ", + "now_playing": "再生中", + "now_playing_title": "再生中:{title}", + "open_home": "メインページを開く", + "quit": "終了" } } diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 69b8b48..3cb74da 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -416,8 +416,11 @@ "color_brown": "棕色", "color_orange": "橙色", "color_green": "绿色", - "dynamic_color": "动态取色", - "dynamic_color_desc": "跟随封面主题色自动调整界面色彩", + "color_mode": "取色方式", + "color_mode_desc": "选择界面主题色的来源", + "color_mode_system": "自动跟随系统取色", + "color_mode_default": "默认取色", + "color_mode_cover": "跟随歌曲封面动态取色", "personalization": "个性化", "nav_personalization_desc": "调整外观和播放界面", "motion": "动效设置", @@ -870,5 +873,14 @@ "copy_line": "复制这一句", "copy_all": "复制全部歌词", "seek_here": "跳到这一句" + }, + "tray": { + "prev": "上一首", + "toggle": "暂停/播放", + "next": "下一首", + "now_playing": "正在播放", + "now_playing_title": "正在播放:{title}", + "open_home": "打开主页面", + "quit": "退出" } } diff --git a/src/i18n/zh-TW.json b/src/i18n/zh-TW.json index f9f90cd..4b20c74 100644 --- a/src/i18n/zh-TW.json +++ b/src/i18n/zh-TW.json @@ -416,8 +416,11 @@ "color_brown": "棕色", "color_orange": "橙色", "color_green": "綠色", - "dynamic_color": "動態取色", - "dynamic_color_desc": "跟隨封面主題色自動調整介面色彩", + "color_mode": "取色方式", + "color_mode_desc": "選擇介面主題色的來源", + "color_mode_system": "自動跟隨系統取色", + "color_mode_default": "預設取色", + "color_mode_cover": "跟隨歌曲封面動態取色", "personalization": "個人化", "nav_personalization_desc": "調整外觀和播放介面", "motion": "動效設定", @@ -870,5 +873,14 @@ "copy_line": "複製這一句", "copy_all": "複製全部歌詞", "seek_here": "跳到這一句" + }, + "tray": { + "prev": "上一首", + "toggle": "暫停/播放", + "next": "下一首", + "now_playing": "正在播放", + "now_playing_title": "正在播放:{title}", + "open_home": "開啟主頁面", + "quit": "結束" } } diff --git a/src/main.ts b/src/main.ts index bb62d3b..b6fa21e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,11 @@ const router = createRouter({ // 崩溃收集才有前端现场可看 const crashLog = createLogger('frontend-crash') window.addEventListener('error', (event) => { + // ResizeObserver 回调内改动被观察元素尺寸时,浏览器必然报一次 + // "loop completed with undelivered notifications"(规范行为,下一帧自愈, + // 无实际危害)。歌词字号是视口相对值,窗口/面板尺寸变化时极易触发, + // 误报成崩溃只会污染日志,此处过滤 + if (event.message === 'ResizeObserver loop completed with undelivered notifications.') return crashLog.error('uncaught error:', event.message, event.filename, `${event.lineno}:${event.colno}`) }) window.addEventListener('unhandledrejection', (event) => { diff --git a/src/stores/player.ts b/src/stores/player.ts index 7a2d819..682956f 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -2283,9 +2283,15 @@ export const usePlayerStore = defineStore('player', () => { ) queue.value = [...tracks] queueIndex.value = startIndex - shuffleBag = [] shuffleHistory = [] shuffleFuture = [] + // 队列整体替换后 shuffleBag 必须按新队列重建(排除当前索引); + // 否则曲目自然结束/手动 next 走 bag 空 -> 提前 return,自动切歌永久卡死 + if (shuffleEnabled.value) { + rebuildShuffleBag() + } else { + shuffleBag = [] + } void play(queue.value[startIndex]) } @@ -2299,9 +2305,13 @@ export const usePlayerStore = defineStore('player', () => { } queue.value = shuffled queueIndex.value = 0 - shuffleBag = [] shuffleHistory = [] shuffleFuture = [] + if (shuffleEnabled.value) { + rebuildShuffleBag() + } else { + shuffleBag = [] + } play(shuffled[0]) } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 36176f8..cf711c2 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core' export type ThemeMode = 'system' | 'dark' | 'light' export type CoverStyle = 'disc' | 'card' +export type ColorMode = 'system' | 'default' | 'cover' export interface AppSettings { formatVersion: number @@ -36,7 +37,7 @@ export interface AppSettings { coverStyle: CoverStyle advancedLyrics: boolean dynamicBackground: boolean - dynamicColor: boolean + colorMode: ColorMode audioReactive: boolean coverBlurBg: boolean coverBlurAmount: number @@ -114,7 +115,7 @@ const DEFAULT_SETTINGS: AppSettings = { coverStyle: 'card', advancedLyrics: true, dynamicBackground: true, - dynamicColor: false, + colorMode: 'default', audioReactive: true, coverBlurBg: false, coverBlurAmount: 1.5, @@ -355,7 +356,7 @@ export const useSettingsStore = defineStore('settings', () => { const coverStyle = ref(initial.coverStyle) const advancedLyrics = ref(initial.advancedLyrics) const dynamicBackground = ref(initial.dynamicBackground) - const dynamicColor = ref(initial.dynamicColor) + const colorMode = ref(initial.colorMode) const audioReactive = ref(initial.audioReactive) const coverBlurBg = ref(initial.coverBlurBg) const coverBlurAmount = ref(initial.coverBlurAmount) @@ -394,7 +395,7 @@ export const useSettingsStore = defineStore('settings', () => { fadeInDuration, fadeOutDuration, crossfadeNext, crossfadeInDuration, crossfadeOutDuration, keepProgress, keepPlaybackMode, showTranslation, lyricBlur, lyricBlurAmount, cloudMusicOffset, qqMusicOffset, coverStyle, - advancedLyrics, dynamicBackground, dynamicColor, audioReactive, coverBlurBg, + advancedLyrics, dynamicBackground, colorMode, audioReactive, coverBlurBg, coverBlurAmount, coverBlurDarken, neteaseQuality, qqMusicQuality, youtubeQuality, biliQuality, bypassProxy, internationalizationEnabled, backgroundImageUri, backgroundImageBlur, backgroundImageAlpha, devModeEnabled, @@ -480,7 +481,7 @@ export const useSettingsStore = defineStore('settings', () => { crossfadeNext, crossfadeInDuration, crossfadeOutDuration, keepProgress, keepPlaybackMode, showTranslation, lyricBlur, lyricBlurAmount, cloudMusicOffset, qqMusicOffset, advancedLyrics, dynamicBackground, - dynamicColor, audioReactive, coverBlurBg, coverBlurAmount, coverBlurDarken, + colorMode, audioReactive, coverBlurBg, coverBlurAmount, coverBlurDarken, neteaseQuality, qqMusicQuality, youtubeQuality, biliQuality, bypassProxy, internationalizationEnabled, backgroundImageUri, backgroundImageBlur, backgroundImageAlpha, devModeEnabled, logToFile, logLevel, maxCacheSize, downloadNameTemplate, diff --git a/src/utils/colorExtractor.ts b/src/utils/colorExtractor.ts index 490c024..8c58b5d 100644 --- a/src/utils/colorExtractor.ts +++ b/src/utils/colorExtractor.ts @@ -161,6 +161,57 @@ export function reapplyDynamicColorForTheme(isDark: boolean): boolean { return true } +/** + * 探测系统强调色作为取色种子。 + * 1) 优先 CSS accent-color: accent 关键字(WebKitGTK 等映射 GTK 主题); + * 2) 失败则走 Rust 命令(Windows 读注册表 SystemAccentColor)。 + * 都不支持时返回 null,调用方回退到默认取色。 + */ +export async function resolveSystemAccentSeed(): Promise { + try { + const probe = document.createElement('input') + probe.type = 'checkbox' + probe.style.position = 'fixed' + probe.style.opacity = '0' + probe.style.pointerEvents = 'none' + probe.style.accentColor = 'accent' + document.body.appendChild(probe) + const computed = getComputedStyle(probe).accentColor + probe.remove() + const match = computed?.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match) return [Number(match[1]), Number(match[2]), Number(match[3])] + } catch { + // 探测失败走平台命令兜底 + } + try { + const { invoke } = await import('@tauri-apps/api/core') + const color = await invoke('get_system_accent_color') + const match = color?.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/) + if (match) return [Number(match[1]), Number(match[2]), Number(match[3])] + } catch { + // 非 Tauri 环境或命令不可用时忽略 + } + return null +} + +/** + * 用指定种子色生成并应用全局动态主题色(system / cover 共用)。 + * @returns 是否成功应用 + */ +export function applyDynamicColorFromSeed(seed: RGB, isDark: boolean): boolean { + const vars = buildDynamicVars(seed, isDark) + beginSmoothThemeTransition() + applyThemeVars(vars, `${seed[0]}, ${seed[1]}, ${seed[2]}`, isDark) + lastSeed = seed + isActive = true + log.info('动态取色已应用(种子模式):', { + seed, + isDark, + primary: vars['--md-primary'], + }) + return true +} + /** * 从封面提取主色并应用为全局动态主题色。 * @returns 是否成功应用 diff --git a/src/utils/theme.ts b/src/utils/theme.ts index ba69145..83b6654 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,5 +1,10 @@ // 深色/浅色模式管理 + 圆形扩散过渡动画 // 参考 Android 端 pending state 模式:视觉切换与持久化解耦,消除卡顿 +import { detectPlatform } from '@/modules/shortcuts/platform' + +// WebKitGTK 的 View Transition 在 Linux 上不稳定(整页快照合成可致 +// WebKit 回调内 SEGV),走 beginSmoothThemeTransition 平滑降级 +const VIEW_TRANSITION_SUPPORTED = detectPlatform() !== 'linux' import { reapplyDynamicColorForTheme } from './colorExtractor' import { applyThemeColor, @@ -58,7 +63,7 @@ export async function switchThemeWithRipple(mode: ThemeMode, x: number, y: numbe // 如果浏览器不支持 View Transition,直接切换(走平滑过渡兜底)。 // 调用方(SettingsView)可能已预加 theme-ripple-active,而本分支不会走 // ripple 的清理流程,先移除以免残留导致全局 transition 被永久禁用 - if (!(document as any).startViewTransition) { + if (!(document as any).startViewTransition || !VIEW_TRANSITION_SUPPORTED) { document.documentElement.classList.remove('theme-ripple-active') applyTheme(mode, persist) return diff --git a/src/utils/themeColor.ts b/src/utils/themeColor.ts index 9302b28..59fb78a 100644 --- a/src/utils/themeColor.ts +++ b/src/utils/themeColor.ts @@ -1,5 +1,10 @@ // 主题色管理:点击色块实时切换全局 CSS 变量 // 色值参考 Material Theme Builder (material-foundation/material-color-utilities) 生成 +import { detectPlatform } from '@/modules/shortcuts/platform' + +// WebKitGTK 的 View Transition 会整页快照合成,在 Linux 上曾导致 +// GTK 主循环内 WebKit 回调 SEGV 崩溃;Linux 走平滑过渡降级路径 +const VIEW_TRANSITION_SUPPORTED = detectPlatform() !== 'linux' export interface ThemeColorScheme { key: string @@ -324,7 +329,7 @@ export function applyThemeColor(key: string, isDark?: boolean, persist = true) { * 参考 Android 端 pending state 模式:先切换视觉 -> 异步持久化 */ export async function switchThemeColorWithRipple(key: string, x: number, y: number, persist = true) { - if (!(document as any).startViewTransition) { + if (!(document as any).startViewTransition || !VIEW_TRANSITION_SUPPORTED) { applyThemeColor(key, undefined, persist) return } diff --git a/src/views/BiliPlaylistView.vue b/src/views/BiliPlaylistView.vue index 1cb1e52..578ddef 100644 --- a/src/views/BiliPlaylistView.vue +++ b/src/views/BiliPlaylistView.vue @@ -13,6 +13,7 @@ import TrackSelectionToolbar from '@/components/TrackSelectionToolbar.vue' import LocateTrackFab from '@/components/LocateTrackFab.vue' import { useTrackSelection } from '@/composables/useTrackSelection' import { useLocateCurrentTrack } from '@/composables/useLocateCurrentTrack' +import { useIncrementalList } from '@/composables/useIncrementalList' import { createContextMenuItem, type ContextMenuActionItem, @@ -73,6 +74,10 @@ const filteredTracks = computed(() => { ) }) +// 大列表分块渲染(WebKitGTK 上千行一次性渲染会卡顿) +const { visibleItems: visibleTracks, onScroll: onTrackListScroll, ensureIndex: ensureTrackIndex } = + useIncrementalList(() => filteredTracks.value) + const { selectionMode, selectedIds, @@ -97,6 +102,7 @@ const { fabVisible: locateFabVisible, locate: locateCurrentTrack } = useLocateCu containerRef: viewRef, currentKey: currentRowKey, suppressed: selectionMode, + ensureRendered: key => ensureTrackIndex(filteredTracks.value.findIndex(item => item.id === key)), }) async function loadDetail() { @@ -334,7 +340,7 @@ onMounted(() => {