3 Commits

Author SHA1 Message Date
e40f82cc51 refactor: 创建统一 API 封装层,前端不再直接调用 invoke
- 新增 src/api.ts,按职责分为 MusicApi/AudioApi/DeviceApi/DownloadApi/AppApi 五个命名空间
- 替换 15 个文件中所有 invoke 调用为 API 层方法
- 后端接口变更只需修改 api.ts 一处,便于后期迭代维护
2026-05-29 22:02:42 +08:00
68f29c8ea8 refactor: 拆分 App.vue 为独立组件,修复多项敷衍方案
- 提取 TitleBar、Sidebar、RoamDrawer、CloseModal 四个组件
- App.vue 从 676 行精简至 238 行,职责更清晰
- 修复评论点赞无限+1:改为基于服务端 liked 字段切换
- 修复评论点赞无防重复:添加 likingSet 锁
- 修复评论点赞用本地缓存:likedIds 改为从服务端 likelist API 同步
- 删除 likedIds 写 localStorage 的 watch
- 播放失败/FM加载失败/减少推荐失败添加 showToast 用户提示
- 抽屉遮罩下标题栏按钮保持原色(z-10 提升至遮罩之上)
2026-05-29 21:13:14 +08:00
57aa9dae61 fix: PlayerBar进度条边框线移除及按钮居中修复,release构建优化
- 移除PlayerBar顶部border线,进度条紧贴边缘无需分隔线
- 减少推荐按钮改为absolute定位,不参与播放控制按钮居中计算
- 删除未使用的npm依赖(howler/axios/@vueuse/motion/@vicons/ionicons5)
- @iconify-json/lucide移至devDependencies
- Cargo.toml添加[profile.release]优化(strip/lto/codegen-units/panic/opt-level)
- crate-type精简,移除不需要的staticlib
- Vite构建优化(esnext目标/CSS合并/vendor chunk拆分)
2026-05-29 17:22:29 +08:00
26 changed files with 923 additions and 1300 deletions

709
package-lock.json generated
View File

@ -1,36 +1,32 @@
{ {
"name": "nekosonic", "name": "nekosonic",
"version": "0.5.1", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nekosonic", "name": "nekosonic",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.110",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"axios": "^1.16.0",
"howler": "^2.2.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.110",
"@iconify/utils": "^3.1.3", "@iconify/utils": "^3.1.3",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vueuse/motion": "^3.0.3",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^23.0.1",
@ -544,6 +540,7 @@
"version": "1.2.110", "version": "1.2.110",
"resolved": "https://registry.npmmirror.com/@iconify-json/lucide/-/lucide-1.2.110.tgz", "resolved": "https://registry.npmmirror.com/@iconify-json/lucide/-/lucide-1.2.110.tgz",
"integrity": "sha512-rLeHqnZZBxZbprbVwf6uY7HB5GkGVgvT9VujhjvaUEqFDLKZON6zR8K1f8uD1brBwf5TJ0TIvvW8mz5u2XJU+w==", "integrity": "sha512-rLeHqnZZBxZbprbVwf6uY7HB5GkGVgvT9VujhjvaUEqFDLKZON6zR8K1f8uD1brBwf5TJ0TIvvW8mz5u2XJU+w==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@iconify/types": "*" "@iconify/types": "*"
@ -553,6 +550,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@iconify/utils": { "node_modules/@iconify/utils": {
@ -616,40 +614,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@nuxt/kit": {
"version": "3.21.4",
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-3.21.4.tgz",
"integrity": "sha512-XDWhQJsA5hpdFpVSmImQIVXcsANJI07TjT1LZC/AUKJxl/dcM52Rq4uU+b3uqyVl4LZR1fODSDEzLxcdXq4Rmg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"c12": "^3.3.4",
"consola": "^3.4.2",
"defu": "^6.1.7",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.6.1",
"klona": "^2.0.6",
"knitwork": "^1.3.0",
"mlly": "^1.8.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.1",
"rc9": "^3.0.1",
"scule": "^1.3.0",
"semver": "^7.7.4",
"tinyglobby": "^0.2.16",
"ufo": "^1.6.4",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.3", "version": "4.60.3",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
@ -1571,20 +1535,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"dev": true,
"license": "MIT"
},
"node_modules/@vicons/ionicons5": {
"version": "0.13.0",
"resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
"integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1797,68 +1747,6 @@
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": {
"version": "13.9.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-13.9.0.tgz",
"integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.9.0",
"@vueuse/shared": "13.9.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.9.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-13.9.0.tgz",
"integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/motion": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@vueuse/motion/-/motion-3.0.3.tgz",
"integrity": "sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vueuse/core": "^13.0.0",
"@vueuse/shared": "^13.0.0",
"defu": "^6.1.4",
"framesync": "^6.1.2",
"popmotion": "^11.0.5",
"style-value-types": "^5.1.2"
},
"optionalDependencies": {
"@nuxt/kit": "^3.13.0"
},
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/@vueuse/shared": {
"version": "13.9.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-13.9.0.tgz",
"integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
@ -1903,23 +1791,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1946,57 +1817,6 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"chokidar": "^5.0.0",
"confbox": "^0.2.4",
"defu": "^6.1.6",
"dotenv": "^17.3.1",
"exsolve": "^1.0.8",
"giget": "^3.2.0",
"jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.1.0",
"pkg-types": "^2.3.0",
"rc9": "^3.0.1"
},
"peerDependencies": {
"magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
@ -2006,34 +1826,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
@ -2063,18 +1855,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/confbox": { "node_modules/confbox": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
@ -2082,17 +1862,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/copy-anything": { "node_modules/copy-anything": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz",
@ -2130,30 +1899,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@ -2170,34 +1915,6 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"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",
@ -2230,59 +1947,6 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/errx": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
@ -2369,52 +2033,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framesync": {
"version": "6.1.2",
"resolved": "https://registry.npmmirror.com/framesync/-/framesync-6.1.2.tgz",
"integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "2.4.0"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@ -2430,15 +2048,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"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",
@ -2448,66 +2057,6 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/giget": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.2.0.tgz",
"integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==",
"dev": true,
"license": "MIT",
"optional": true,
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -2515,45 +2064,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/he": { "node_modules/he": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
@ -2564,36 +2074,12 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/hey-listen/-/hey-listen-1.0.8.tgz",
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==",
"dev": true,
"license": "MIT"
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/howler": {
"version": "2.2.4",
"resolved": "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz",
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/import-meta-resolve": { "node_modules/import-meta-resolve": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
@ -2636,25 +2122,6 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/knitwork": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@ -2955,36 +2422,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
@ -3075,14 +2512,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
@ -3216,19 +2645,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/popmotion": {
"version": "11.0.5",
"resolved": "https://registry.npmmirror.com/popmotion/-/popmotion-11.0.5.tgz",
"integrity": "sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==",
"dev": true,
"license": "MIT",
"dependencies": {
"framesync": "6.1.2",
"hey-listen": "^1.0.8",
"style-value-types": "5.1.2",
"tslib": "2.4.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.14", "version": "8.5.14",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
@ -3257,15 +2673,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/qrcode": { "node_modules/qrcode": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
@ -3300,33 +2707,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"defu": "^6.1.6",
"destr": "^2.0.5"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"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",
@ -3393,28 +2773,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": { "node_modules/set-blocking": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
@ -3465,17 +2823,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/style-value-types": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/style-value-types/-/style-value-types-5.1.2.tgz",
"integrity": "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"hey-listen": "^1.0.8",
"tslib": "2.4.0"
}
},
"node_modules/superjson": { "node_modules/superjson": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz",
@ -3541,7 +2888,8 @@
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD",
"optional": true
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.6.3",
@ -3565,31 +2913,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unctx": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"acorn": "^8.15.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21",
"unplugin": "^2.3.11"
}
},
"node_modules/unctx/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.19.2", "version": "7.19.2",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz",
@ -3650,24 +2973,6 @@
} }
} }
}, },
"node_modules/untyped": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"citty": "^0.1.6",
"defu": "^6.1.4",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"scule": "^1.3.0"
},
"bin": {
"untyped": "dist/cli.mjs"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.2",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",

View File

@ -10,29 +10,25 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.110",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"axios": "^1.16.0",
"howler": "^2.2.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/lucide": "^1.2.110",
"@iconify/utils": "^3.1.3", "@iconify/utils": "^3.1.3",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vueuse/motion": "^3.0.3",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^23.0.1",

View File

@ -12,7 +12,7 @@ edition = "2021"
# to make the lib name unique and wouldn't conflict with the bin name. # to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "demo_lib" name = "demo_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
@ -49,3 +49,9 @@ raw-window-handle = "0.6"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
souvlaki = "0.8" souvlaki = "0.8"
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
opt-level = "s"

View File

@ -1,130 +1,9 @@
<template> <template>
<div class="flex flex-col h-screen bg-base text-content overflow-hidden"> <div class="flex flex-col h-screen bg-base text-content overflow-hidden">
<div <TitleBar @close="closeWindow" />
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
<div class="flex items-center gap-1.5">
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
<div class="flex flex-1 overflow-hidden" v-if="windowVisible"> <div class="flex flex-1 overflow-hidden" v-if="windowVisible">
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur"> <Sidebar />
<div class="flex-1 p-4 overflow-y-auto min-h-0">
<div class="flex flex-col min-h-full">
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHome class="w-[18px] h-[18px]" />
推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSearch class="w-[18px] h-[18px]" />
发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
>
<IconRadio class="w-[18px] h-[18px]" />
漫游
</button>
</div>
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
<div class="space-y-0.5">
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHeart class="w-[18px] h-[18px]" />
我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconClock class="w-[18px] h-[18px]" />
最近播放
</router-link>
<router-link to="/local-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconMusic class="w-[18px] h-[18px]" />
本地音乐
</router-link>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-content-3">我的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-content-3">收藏的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
<div class="px-1">
<router-link to="/settings"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSettings class="w-[18px] h-[18px]" />
设置
</router-link>
</div>
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
<IconLogIn class="w-4 h-4" />
立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2 mt-3">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout(); player.stop()"
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
</div>
</div>
</div>
</div>
</div>
</nav>
<main class="flex-1 overflow-y-auto pb-24"> <main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
@ -135,101 +14,7 @@
</main> </main>
</div> </div>
<Transition name="drawer"> <RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
<div
v-if="windowVisible && player.showRoamDrawer"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
:class="!player.dominantColor && 'bg-surface/95'"
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
>
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0 relative z-10" data-tauri-drag-region>
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
<IconChevronDown class="w-5 h-5" />
</button>
<div class="flex items-center gap-1.5">
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
<img
v-if="roamCoverUrl && !roamCoverError"
:src="roamCoverUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
@error="roamCoverError = true"
/>
<div
v-else
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
>
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
</div>
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : 'text-content-3'">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
<template v-if="roamSong?.al?.name">
<span :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
</template>
</p>
</div>
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
<div class="flex items-center gap-1 mb-3 px-4">
<button @click="roamTab = 'lyric'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
歌词
</button>
<button @click="roamTab = 'comment'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
评论
</button>
<button v-if="hasTranslation" @click="toggleTranslation"
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
:class="player.dominantColor
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
<IconLanguages class="w-3 h-3" />
</button>
</div>
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="getRoamLyricClass(idx)"
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-all duration-300"
@click="seekToRoamLyric(line.time)"
@mouseenter="roamLyricHovering = true"
@mouseleave="roamLyricHovering = false"
>
{{ line.text }}
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
</p>
</div>
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
</div>
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" />
</div>
</div>
</div>
</div>
</Transition>
<PlayerBar v-if="player.currentSong" /> <PlayerBar v-if="player.currentSong" />
<ToastContainer /> <ToastContainer />
@ -243,82 +28,34 @@
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')" @ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
/> />
<Transition name="fade"> <CloseModal
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false"> :visible="showCloseModal"
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto"> @confirm="handleCloseAction"
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2> @cancel="showCloseModal = false"
<p class="text-sm text-content-2 mb-5">你希望如何处理</p> />
<div class="space-y-2.5 mb-4">
<button @click="handleCloseAction('minimize')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
</div>
<div>
<p class="text-sm font-medium text-content">最小化到托盘</p>
<p class="text-xs text-content-3">程序继续在后台运行</p>
</div>
</button>
<button @click="handleCloseAction('exit')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
<IconX class="w-[18px] h-[18px] text-danger" />
</div>
<div>
<p class="text-sm font-medium text-content">退出程序</p>
<p class="text-xs text-content-3">完全关闭应用程序</p>
</div>
</button>
</div>
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
<input type="checkbox" v-model="closeDontAskAgain" />
<span class="text-xs text-content-2">不再询问记住我的选择</span>
</label>
<button @click="showCloseModal = false"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</Transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue'; import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from './stores/user'; import { useUserStore } from './stores/user';
import { useSettingsStore, type CloseAction } from './stores/settings'; import { useSettingsStore, type CloseAction } from './stores/settings';
import { usePlayerStore } from './stores/player';
import TitleBar from './components/TitleBar.vue';
import Sidebar from './components/Sidebar.vue';
import RoamDrawer from './components/RoamDrawer.vue';
import PlayerBar from './components/PlayerBar.vue'; import PlayerBar from './components/PlayerBar.vue';
import ToastContainer from './components/ToastContainer.vue'; import ToastContainer from './components/ToastContainer.vue';
import CommentSection from './components/CommentSection.vue'; import CloseModal from './components/CloseModal.vue';
import UpdateDialog from './components/UpdateDialog.vue'; import UpdateDialog from './components/UpdateDialog.vue';
import { usePlayerStore } from './stores/player';
import { getCoverUrl, extractDominantColor } from './utils/song';
import { useOnlineStatus } from './composables/useOnlineStatus'; import { useOnlineStatus } from './composables/useOnlineStatus';
import { showToast } from './composables/useToast'; import { showToast } from './composables/useToast';
import { useLyric } from './composables/UserLyric';
import { useUpdater } from './composables/useUpdater'; import { useUpdater } from './composables/useUpdater';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { register, unregister } from '@tauri-apps/plugin-global-shortcut'; import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
import IconHome from '~icons/lucide/home'; import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
import IconSearch from '~icons/lucide/search';
import IconRadio from '~icons/lucide/radio';
import IconHeart from '~icons/lucide/heart';
import IconSettings from '~icons/lucide/settings';
import IconLogIn from '~icons/lucide/log-in';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconChevronRight from '~icons/lucide/chevron-right';
import IconMaximize2 from '~icons/lucide/maximize-2';
import IconX from '~icons/lucide/x';
import IconClock from '~icons/lucide/clock';
import IconMusic from '~icons/lucide/music';
import IconLanguages from '~icons/lucide/languages';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const player = usePlayerStore(); const player = usePlayerStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -330,12 +67,7 @@ watch(isOnline, (val, old) => {
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error'); else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
}); });
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
const showCloseModal = ref(false); const showCloseModal = ref(false);
const closeDontAskAgain = ref(false);
const windowVisible = ref(true); const windowVisible = ref(true);
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']); const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
@ -343,136 +75,8 @@ watch(() => settings.dataTheme, (val) => {
document.documentElement.setAttribute('data-theme', val); document.documentElement.setAttribute('data-theme', val);
}, { immediate: true }); }, { immediate: true });
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
const roamSong = computed(() => player.currentSong);
const roamCoverError = ref(false);
const roamTab = ref<'lyric' | 'comment'>('lyric');
const roamCoverUrl = computed(() => {
if (!roamSong.value) return '';
return getCoverUrl(roamSong.value) || '';
});
watch(roamCoverUrl, async (url) => {
roamCoverError.value = false;
if (url) {
const color = await extractDominantColor(url);
player.dominantColor = color;
} else {
player.dominantColor = '';
}
});
let roamResizeObserver: ResizeObserver | null = null;
function updateRoamLyricPad() {
if (lyricScrollContainer.value) {
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
}
}
watch(() => player.showRoamDrawer, (val) => {
if (val) {
roamTab.value = player.roamInitialTab;
nextTick(() => {
updateRoamLyricPad();
if (roamResizeObserver) roamResizeObserver.disconnect();
if (lyricScrollContainer.value) {
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
roamResizeObserver.observe(lyricScrollContainer.value);
}
scrollToRoamActiveLyric();
});
} else {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
}
});
onBeforeUnmount(() => {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
watch(showTranslation, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
function scrollToRoamActiveLyric() {
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
if (active) {
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function getRoamLyricClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
const hasColor = !!player.dominantColor;
if (idx === currentLyricIdx.value) {
return 'roam-lyric-active text-accent-text font-semibold text-xl';
}
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
}
function seekToRoamLyric(time: number) {
if (time != null && player.duration > 0) {
player.seek(time);
}
}
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
player.closeRoamDrawer();
router.push(routeLocation);
}
async function openRoamFromSidebar() {
if (!userStore.isLoggedIn) {
router.push('/login');
return;
}
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
watch(() => userStore.isLoggedIn, (val) => { watch(() => userStore.isLoggedIn, (val) => {
if (val) { if (val) {
loadPlaylists();
player.loadLikedIds(); player.loadLikedIds();
} }
}); });
@ -481,12 +85,11 @@ onMounted(async () => {
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
loadPlaylists();
player.loadLikedIds(); player.loadLikedIds();
} }
try { await invoke('stop_audio'); } catch { /* 忽略 */ } try { await AudioApi.stopAudio(); } catch { /* 忽略 */ }
try { try {
const jsonStr: string = await invoke('get_login_status'); const jsonStr: string = await MusicApi.getLoginStatus();
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.account || data.profile) { if (data.account || data.profile) {
const profile = data.profile || data.account; const profile = data.profile || data.account;
@ -500,39 +103,34 @@ onMounted(async () => {
updater.checkForUpdate(true); updater.checkForUpdate(true);
// 恢复保存的输出设备设置
if (settings.outputDevice) { if (settings.outputDevice) {
try { try {
await invoke('set_output_device', { device: settings.outputDevice }); await DeviceApi.setOutputDevice(settings.outputDevice);
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
} }
}); });
const currentWindow = getCurrentWindow(); const currentWindow = getCurrentWindow();
function minimizeWindow() { currentWindow.minimize(); }
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
}
function closeWindow() { function closeWindow() {
if (settings.closeAction === 'ask') { if (settings.closeAction === 'ask') {
closeDontAskAgain.value = false;
showCloseModal.value = true; showCloseModal.value = true;
} else if (settings.closeAction === 'minimize') { } else if (settings.closeAction === 'minimize') {
currentWindow.hide(); currentWindow.hide();
} else { } else {
invoke('exit_app'); AppApi.exitApp();
} }
} }
function handleCloseAction(action: CloseAction) {
if (closeDontAskAgain.value) { function handleCloseAction(action: CloseAction, remember: boolean) {
if (remember) {
settings.setCloseAction(action); settings.setCloseAction(action);
} }
showCloseModal.value = false; showCloseModal.value = false;
if (action === 'minimize') { if (action === 'minimize') {
currentWindow.hide(); currentWindow.hide();
} else { } else {
invoke('exit_app'); AppApi.exitApp();
} }
} }
@ -638,21 +236,3 @@ onMounted(() => {
}); });
}); });
</script> </script>
<style>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
.roam-lyric-line:hover {
background: var(--c-subtle);
}
.roam-lyric-active:hover {
background: var(--c-subtle) !important;
}
</style>

196
src/api.ts Normal file
View File

@ -0,0 +1,196 @@
import { invoke } from '@tauri-apps/api/core';
export namespace MusicApi {
export async function getLoginStatus(): Promise<string> {
return invoke('get_login_status');
}
export async function logout(): Promise<void> {
return invoke('logout');
}
export async function getQrKey(): Promise<string> {
return invoke('get_qr_key');
}
export async function checkQrStatus(key: string): Promise<string> {
return invoke('check_qr_status', { query: { key } });
}
export async function likelist(uid: number): Promise<string> {
return invoke('likelist', { uid });
}
export async function likeSong(id: number, like: boolean): Promise<void> {
return invoke('like_song', { query: { id, like: like ? 'true' : 'false' } });
}
export async function userPlaylist(uid: number): Promise<string> {
return invoke('user_playlist', { uid });
}
export async function getPlaylistDetail(id: number): Promise<string> {
return invoke('get_playlist_detail', { id });
}
export async function playlistTrackAll(id: number): Promise<string> {
return invoke('playlist_track_all', { query: { id } });
}
export async function playlistSubscribe(id: number, subscribe: boolean): Promise<void> {
return invoke('playlist_subscribe', { query: { id, subscribe } });
}
export async function recommendResource(): Promise<string> {
return invoke('recommend_resource');
}
export async function recommendSongs(): Promise<string> {
return invoke('recommend_songs');
}
export async function getSongDetail(id: string): Promise<string> {
return invoke('get_song_detail', { id });
}
export async function getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise<string> {
return invoke('get_song_url', { query });
}
export async function getLyric(id: number): Promise<string> {
return invoke('get_lyric', { id });
}
export async function searchSuggest(keyword: string): Promise<string> {
return invoke('search_suggest', { query: { keyword } });
}
export async function getHotSearch(): Promise<string> {
return invoke('get_hot_search');
}
export async function cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise<string> {
return invoke('cloudsearch', { query });
}
export async function albumDetail(id: number): Promise<string> {
return invoke('album_detail', { id });
}
export async function artistDetail(id: number): Promise<string> {
return invoke('artist_detail', { id });
}
export async function artistSongs(query: { id: number; order: string; limit: number; offset: number }): Promise<string> {
return invoke('artist_songs', { query });
}
export async function artistAlbum(id: number, limit: number, offset: number): Promise<string> {
return invoke('artist_album', { id, limit, offset });
}
export async function artistDesc(id: number): Promise<string> {
return invoke('artist_desc', { id });
}
export async function commentHot(query: { type: number; id: number; limit: number; offset: number }): Promise<string> {
return invoke('comment_hot', { query });
}
export async function commentLike(query: { t: number; type: number; id: number; cid: number }): Promise<void> {
return invoke('comment_like', { query });
}
export async function personalFm(): Promise<string> {
return invoke('personal_fm');
}
export async function personalFmMode(query: { mode: string; subMode: string; limit: number }): Promise<string> {
return invoke('personal_fm_mode', { query });
}
export async function fmTrash(id: number, time: number): Promise<void> {
return invoke('fm_trash', { query: { id, time } });
}
export async function scrobble(query: { id: number; sourceid: string; time: number }): Promise<void> {
return invoke('scrobble', { query });
}
}
export namespace AudioApi {
export async function playAudio(url: string): Promise<void> {
return invoke('play_audio', { url });
}
export async function playLocalAudio(path: string): Promise<void> {
return invoke('play_local_audio', { path });
}
export async function pauseAudio(): Promise<void> {
return invoke('pause_audio');
}
export async function resumeAudio(): Promise<void> {
return invoke('resume_audio');
}
export async function stopAudio(): Promise<void> {
return invoke('stop_audio');
}
export async function seekAudio(time: number): Promise<void> {
return invoke('seek_audio', { time });
}
export async function setVolume(vol: number): Promise<void> {
return invoke('set_volume', { vol });
}
export async function getAudioPosition(): Promise<number> {
return invoke('get_audio_position');
}
}
export namespace DeviceApi {
export async function getOutputDevices(): Promise<string[]> {
return invoke('get_output_devices');
}
export async function setOutputDevice(device: string | null): Promise<void> {
return invoke('set_output_device', { device });
}
}
export namespace DownloadApi {
export async function downloadSong(query: {
id: number;
name: string;
artist: string;
album: string | null;
duration: number | null;
coverUrl: string | null;
level: string;
downloadPath: string | null;
}): Promise<void> {
return invoke('download_song', { query });
}
export async function listLocalSongs(downloadPath: string | null): Promise<any[]> {
return invoke('list_local_songs', { downloadPath });
}
export async function deleteLocalSong(query: { id: number; filename: string; downloadPath: string | null }): Promise<void> {
return invoke('delete_local_song', { query });
}
export async function getDefaultDownloadPath(): Promise<string> {
return invoke('get_default_download_path');
}
}
export namespace AppApi {
export function exitApp(): Promise<void> {
return invoke('exit_app');
}
}

View File

@ -0,0 +1,69 @@
<template>
<Transition name="fade">
<div v-if="visible" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="$emit('cancel')">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
<p class="text-sm text-content-2 mb-5">你希望如何处理</p>
<div class="space-y-2.5 mb-4">
<button @click="handleAction('minimize')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
</div>
<div>
<p class="text-sm font-medium text-content">最小化到托盘</p>
<p class="text-xs text-content-3">程序继续在后台运行</p>
</div>
</button>
<button @click="handleAction('exit')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
<IconX class="w-[18px] h-[18px] text-danger" />
</div>
<div>
<p class="text-sm font-medium text-content">退出程序</p>
<p class="text-xs text-content-3">完全关闭应用程序</p>
</div>
</button>
</div>
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
<input type="checkbox" v-model="dontAskAgain" />
<span class="text-xs text-content-2">不再询问记住我的选择</span>
</label>
<button @click="$emit('cancel')"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { CloseAction } from '../stores/settings';
import IconMaximize2 from '~icons/lucide/maximize-2';
import IconX from '~icons/lucide/x';
defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
confirm: [action: CloseAction, remember: boolean];
cancel: [];
}>();
const dontAskAgain = ref(false);
function handleAction(action: CloseAction) {
emit('confirm', action, dontAskAgain.value);
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>

View File

@ -1,32 +1,34 @@
<template> <template>
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer"> <div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
<div v-if="loading" class="py-8 text-center text-content-2 text-sm">加载中...</div> <div v-if="loading" class="py-8 text-center text-sm" :class="darkMode ? 'text-white/60' : 'text-content-2'">加载中...</div>
<div v-else-if="comments.length === 0" class="py-8 text-center"> <div v-else-if="comments.length === 0" class="py-8 text-center">
<IconMessageSquare class="mx-auto mb-2 text-content-3 w-10 h-10" /> <IconMessageSquare class="mx-auto mb-2 w-10 h-10" :class="darkMode ? 'text-white/40' : 'text-content-3'" />
<p class="text-content-3 text-sm">暂无评论</p> <p class="text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">暂无评论</p>
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
v-for="comment in comments" v-for="comment in comments"
:key="comment.commentId" :key="comment.commentId"
class="p-3 rounded-xl bg-surface/50" class="p-3 rounded-xl"
:class="darkMode ? 'bg-white/8' : 'bg-surface/50'"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" /> <img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-content truncate">{{ comment.user.nickname }}</p> <p class="text-sm font-medium truncate" :class="darkMode ? 'text-white/90' : 'text-content'">{{ comment.user.nickname }}</p>
<p class="text-xs text-content-3">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p> <p class="text-xs" :class="darkMode ? 'text-white/40' : 'text-content-3'">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
</div> </div>
</div> </div>
<p class="mt-2 text-sm text-content-2 leading-relaxed">{{ comment.content }}</p> <p class="mt-2 text-sm leading-relaxed" :class="darkMode ? 'text-white/70' : 'text-content-2'">{{ comment.content }}</p>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end">
<button <button
@click="likeComment(comment.commentId)" @click="likeComment(comment.commentId)"
class="flex items-center gap-1 text-content-3 hover:text-danger transition text-xs" class="flex items-center gap-1 transition text-xs"
:class="comment.liked ? 'text-danger' : (darkMode ? 'text-white/40 hover:text-danger' : 'text-content-3 hover:text-danger')"
> >
<IconHeart style="font-size: 14px" /> <IconHeart style="font-size: 14px" :class="comment.liked ? '[&>path]:fill-current [&>path]:stroke-0' : ''" />
<span>{{ comment.likedCount }}</span> <span>{{ comment.likedCount }}</span>
</button> </button>
</div> </div>
@ -34,19 +36,20 @@
<div ref="sentinel" class="h-1"></div> <div ref="sentinel" class="h-1"></div>
</div> </div>
<div v-if="loadingMore" class="py-4 text-center text-content-3 text-sm">加载中...</div> <div v-if="loadingMore" class="py-4 text-center text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">加载中...</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { invoke } from '@tauri-apps/api/core' import { MusicApi } from '../api'
import IconMessageSquare from '~icons/lucide/message-square' import IconMessageSquare from '~icons/lucide/message-square'
import IconHeart from '~icons/lucide/heart' import IconHeart from '~icons/lucide/heart'
const props = defineProps<{ const props = defineProps<{
type: number type: number
id: number id: number
darkMode?: boolean
}>() }>()
const comments = ref<any[]>([]) const comments = ref<any[]>([])
@ -74,13 +77,11 @@ async function fetchComments(reset = false) {
} }
try { try {
const jsonStr: string = await invoke('comment_hot', { const jsonStr: string = await MusicApi.commentHot({
query: {
type: props.type, type: props.type,
id: props.id, id: props.id,
limit: pageSize, limit: pageSize,
offset: (pageNo.value - 1) * pageSize offset: (pageNo.value - 1) * pageSize
}
}) })
const data = JSON.parse(jsonStr) const data = JSON.parse(jsonStr)
const list = data.hotComments || [] const list = data.hotComments || []
@ -104,22 +105,27 @@ function loadMore() {
fetchComments() fetchComments()
} }
const likingSet = ref(new Set<number>())
async function likeComment(cid: number) { async function likeComment(cid: number) {
if (likingSet.value.has(cid)) return
const target = comments.value.find(c => c.commentId === cid)
if (!target) return
const liked = !!target.liked
likingSet.value.add(cid)
try { try {
await invoke('comment_like', { await MusicApi.commentLike({
query: { t: liked ? 0 : 1,
t: 1,
type: props.type, type: props.type,
id: props.id, id: props.id,
cid cid
}
}) })
const target = comments.value.find(c => c.commentId === cid) target.liked = !liked
if (target) { target.likedCount += liked ? -1 : 1
target.likedCount++
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally {
likingSet.value.delete(cid)
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none" class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur z-50 select-none"
> >
<div v-if="player.dominantColor" <div v-if="player.dominantColor"
class="absolute inset-0 pointer-events-none transition-opacity duration-300" class="absolute inset-0 pointer-events-none transition-opacity duration-300"
@ -57,7 +57,7 @@
</div> </div>
<div class="flex-1 flex flex-col items-center justify-center gap-1"> <div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5"> <div class="flex items-center gap-5 relative">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[ <button @click="player.prev()" :disabled="player.isFmMode" :class="[
'transition', 'transition',
player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'), player.isFmMode ? (drawerActive ? 'text-white/20 cursor-not-allowed' : 'text-content-4 cursor-not-allowed') : (drawerActive ? 'text-white/70 hover:text-white' : 'text-content-2 hover:text-content'),
@ -73,7 +73,7 @@
<button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'"> <button @click="player.next()" :class="drawerActive ? 'text-white/70 hover:text-white transition' : 'text-content-2 hover:text-content transition'">
<IconSkipForward class="w-5 h-5" /> <IconSkipForward class="w-5 h-5" />
</button> </button>
<button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐"> <button v-if="player.isFmMode && player.currentSong" @click="showDislikeModal = true" class="absolute left-full ml-5" :class="drawerActive ? 'text-white/50 hover:text-danger transition' : 'text-content-3 hover:text-danger transition'" title="减少推荐">
<IconHeartOff class="w-[18px] h-[18px]" /> <IconHeartOff class="w-[18px] h-[18px]" />
</button> </button>
</div> </div>
@ -216,7 +216,7 @@ import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format'; import { formatTime } from '../utils/format';
import { getCoverUrl } from '../utils/song'; import { getCoverUrl } from '../utils/song';
import { invoke } from '@tauri-apps/api/core'; import { AudioApi } from '../api';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@ -292,7 +292,7 @@ function toggleMute() {
} else { } else {
player.volume = prevVolume.value || 100; player.volume = prevVolume.value || 100;
} }
invoke('set_volume', { vol: player.volume / 100 }); AudioApi.setVolume(player.volume / 100);
} }
let onDocMove: ((e: MouseEvent) => void) | null = null; let onDocMove: ((e: MouseEvent) => void) | null = null;
@ -418,7 +418,7 @@ async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10); const val = parseInt(target.value, 10);
player.volume = val; player.volume = val;
await invoke('set_volume', { vol: val / 100 }); await AudioApi.setVolume(val / 100);
} }
const volumeBarBg = computed(() => { const volumeBarBg = computed(() => {

View File

@ -0,0 +1,245 @@
<template>
<Transition name="drawer">
<div
v-if="visible"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
:class="!player.dominantColor && 'bg-surface/95'"
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
>
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
<TitleBar :dark-mode="!!player.dominantColor" @close="player.closeRoamDrawer()">
<template #left>
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
<IconChevronDown class="w-5 h-5" />
</button>
</template>
</TitleBar>
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
<img
v-if="roamCoverUrl && !roamCoverError"
:src="roamCoverUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
@error="roamCoverError = true"
/>
<div
v-else
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
>
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
</div>
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : 'text-content-3'">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
<template v-if="roamSong?.al?.name">
<span :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
</template>
</p>
</div>
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
<div class="flex items-center gap-1 mb-3 px-4">
<button @click="roamTab = 'lyric'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
歌词
</button>
<button @click="roamTab = 'comment'"
class="px-3 py-1 rounded-full text-sm transition"
:class="player.dominantColor
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
评论
</button>
<button v-if="hasTranslation" @click="toggleTranslation"
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
:class="player.dominantColor
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
<IconLanguages class="w-3 h-3" />
</button>
</div>
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="getRoamLyricClass(idx)"
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
@click="seekToRoamLyric(line.time)"
@mouseenter="roamLyricHovering = true"
@mouseleave="roamLyricHovering = false"
>
{{ line.text }}
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
</p>
</div>
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
</div>
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player';
import { getCoverUrl, extractDominantColor } from '../utils/song';
import { useLyric } from '../composables/UserLyric';
import TitleBar from './TitleBar.vue';
import CommentSection from './CommentSection.vue';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconMusic from '~icons/lucide/music';
import IconLanguages from '~icons/lucide/languages';
defineProps<{
visible: boolean;
}>();
const router = useRouter();
const player = usePlayerStore();
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
const roamSong = computed(() => player.currentSong);
const roamCoverError = ref(false);
const roamTab = ref<'lyric' | 'comment'>('lyric');
const roamCoverUrl = computed(() => {
if (!roamSong.value) return '';
return getCoverUrl(roamSong.value) || '';
});
watch(roamCoverUrl, async (url) => {
roamCoverError.value = false;
if (url) {
const color = await extractDominantColor(url);
player.dominantColor = color;
} else {
player.dominantColor = '';
}
});
let roamResizeObserver: ResizeObserver | null = null;
function updateRoamLyricPad() {
if (lyricScrollContainer.value) {
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
}
}
watch(() => player.showRoamDrawer, (val) => {
if (val) {
roamTab.value = player.roamInitialTab;
nextTick(() => {
updateRoamLyricPad();
if (roamResizeObserver) roamResizeObserver.disconnect();
if (lyricScrollContainer.value) {
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
roamResizeObserver.observe(lyricScrollContainer.value);
}
scrollToRoamActiveLyric();
});
} else {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
}
});
onBeforeUnmount(() => {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
watch(showTranslation, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
function scrollToRoamActiveLyric() {
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
const container = lyricScrollContainer.value;
const active = container.querySelector('.roam-lyric-active') as HTMLElement | null;
if (!active) return;
const target = active.offsetTop - container.clientHeight / 2 + active.clientHeight / 2;
const start = container.scrollTop;
const distance = target - start;
if (Math.abs(distance) < 1) return;
const duration = 400;
const startTime = performance.now();
function animate(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3);
container.scrollTop = start + distance * ease;
if (progress < 1) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
function getTranslationClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
const hasColor = !!player.dominantColor;
if (idx === currentLyricIdx.value) return hasColor ? 'text-[var(--c-accent)]' : 'text-accent-text';
if (diff === 1) return hasColor ? 'text-white/70' : 'text-content/70';
if (diff === 2) return hasColor ? 'text-white/50' : 'text-content-2/50';
return hasColor ? 'text-white/35' : 'text-content-3/35';
}
function getRoamLyricClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
const hasColor = !!player.dominantColor;
if (idx === currentLyricIdx.value) {
return hasColor
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
: 'roam-lyric-active text-accent-text font-semibold text-xl';
}
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
}
function seekToRoamLyric(time: number) {
if (time != null && player.duration > 0) {
player.seek(time);
}
}
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
player.closeRoamDrawer();
router.push(routeLocation);
}
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
</style>

184
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,184 @@
<template>
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto min-h-0">
<div class="flex flex-col min-h-full">
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHome class="w-[18px] h-[18px]" />
推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSearch class="w-[18px] h-[18px]" />
发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
>
<IconRadio class="w-[18px] h-[18px]" />
漫游
</button>
</div>
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
<div class="space-y-0.5">
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconHeart class="w-[18px] h-[18px]" />
我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconClock class="w-[18px] h-[18px]" />
最近播放
</router-link>
<router-link to="/local-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconMusic class="w-[18px] h-[18px]" />
本地音乐
</router-link>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-content-3">我的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-content-3">收藏的歌单</p>
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
<span class="text-sm truncate"
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
</div>
</div>
</div>
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
<div class="px-1">
<router-link to="/settings"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<IconSettings class="w-[18px] h-[18px]" />
设置
</router-link>
</div>
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
<IconLogIn class="w-4 h-4" />
立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2 mt-3">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout(); player.stop()"
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
</div>
</div>
</div>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
import { MusicApi } from '../api';
import IconHome from '~icons/lucide/home';
import IconSearch from '~icons/lucide/search';
import IconRadio from '~icons/lucide/radio';
import IconHeart from '~icons/lucide/heart';
import IconSettings from '~icons/lucide/settings';
import IconLogIn from '~icons/lucide/log-in';
import IconChevronRight from '~icons/lucide/chevron-right';
import IconClock from '~icons/lucide/clock';
import IconMusic from '~icons/lucide/music';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const player = usePlayerStore();
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await MusicApi.userPlaylist(userStore.user.userId);
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
async function openRoamFromSidebar() {
if (!userStore.isLoggedIn) {
router.push('/login');
return;
}
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) {
loadPlaylists();
player.loadLikedIds();
}
});
onMounted(() => {
if (userStore.isLoggedIn) {
loadPlaylists();
}
});
</script>

View File

@ -0,0 +1,43 @@
<template>
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
:class="darkMode ? '' : 'bg-surface/90 backdrop-blur'"
>
<slot name="left">
<span v-if="!darkMode" class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
</slot>
<div class="flex items-center gap-1.5">
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<button @click="$emit('close')" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
defineProps<{
darkMode?: boolean;
}>();
defineEmits<{
close: [];
}>();
const currentWindow = getCurrentWindow();
function minimizeWindow() {
currentWindow.minimize();
}
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) {
currentWindow.unmaximize();
} else {
currentWindow.maximize();
}
}
</script>

View File

@ -1,7 +1,7 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric'; import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { MusicApi } from '../api';
export function useLyric() { export function useLyric() {
const player = usePlayerStore(); const player = usePlayerStore();
@ -19,7 +19,7 @@ export function useLyric() {
return; return;
} }
try { try {
const jsonStr: string = await invoke('get_lyric', { id: song.id }); const jsonStr: string = await MusicApi.getLyric(song.id);
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || ''; const lrc = data?.lrc?.lyric || '';
const tLrc = data?.tlyric?.lyric || ''; const tLrc = data?.tlyric?.lyric || '';

View File

@ -1,9 +1,9 @@
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { getCoverUrl, type Song } from '../utils/song'; import { getCoverUrl, type Song } from '../utils/song';
import { DownloadApi } from '../api';
interface DownloadTask { interface DownloadTask {
id: number; id: number;
@ -42,7 +42,7 @@ async function setupDownloadListener() {
async function refreshLocalIds() { async function refreshLocalIds() {
try { try {
const settings = useSettingsStore(); const settings = useSettingsStore();
const list: { id: number }[] = await invoke('list_local_songs', { downloadPath: settings.downloadPath || null }); const list: { id: number }[] = await DownloadApi.listLocalSongs(settings.downloadPath || null);
localSongIds.clear(); localSongIds.clear();
for (const s of list) { for (const s of list) {
localSongIds.add(s.id); localSongIds.add(s.id);
@ -90,8 +90,7 @@ async function downloadSong(song: Song) {
tasks.push({ id: song.id, name: song.name, progress: 0 }); tasks.push({ id: song.id, name: song.name, progress: 0 });
try { try {
await invoke('download_song', { await DownloadApi.downloadSong({
query: {
id: song.id, id: song.id,
name: song.name, name: song.name,
artist, artist,
@ -100,7 +99,6 @@ async function downloadSong(song: Song) {
coverUrl, coverUrl,
level: settings.audioQuality, level: settings.audioQuality,
downloadPath: settings.downloadPath || null, downloadPath: settings.downloadPath || null,
},
}); });
localSongIds.add(song.id); localSongIds.add(song.id);
} catch (e: any) { } catch (e: any) {

View File

@ -1,10 +1,10 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch, nextTick } from 'vue'; import { ref, watch, nextTick } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong, type Song } from '../utils/song'; import { normalizeSong, type Song } from '../utils/song';
import { useSettingsStore } from './settings'; import { useSettingsStore } from './settings';
import { useUserStore } from './user'; import { useUserStore } from './user';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
import { MusicApi, AudioApi } from '../api';
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one'; export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
export type { Song }; export type { Song };
@ -20,14 +20,6 @@ function loadRecentLocal(): Song[] {
return []; return [];
} }
function loadLikedIdsFromStorage(): Set<number> {
try {
const raw = localStorage.getItem('liked_ids');
if (raw) return new Set(JSON.parse(raw));
} catch { /* 忽略 */ }
return new Set();
}
export const usePlayerStore = defineStore('player', () => { export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null); const currentSong = ref<Song | null>(null);
const playing = ref(false); const playing = ref(false);
@ -53,7 +45,7 @@ export const usePlayerStore = defineStore('player', () => {
const recentLocal = ref<Song[]>(loadRecentLocal()); const recentLocal = ref<Song[]>(loadRecentLocal());
const MAX_RECENT = 200; const MAX_RECENT = 200;
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage()); const likedIds = ref<Set<number>>(new Set());
function emitPlaybackState() { function emitPlaybackState() {
const song = currentSong.value; const song = currentSong.value;
@ -78,18 +70,20 @@ export const usePlayerStore = defineStore('player', () => {
const userStore = useUserStore(); const userStore = useUserStore();
if (!userStore.isLoggedIn) return; if (!userStore.isLoggedIn) return;
try { try {
const json: string = await invoke('likelist', { uid: userStore.user!.userId }); const json = await MusicApi.likelist(userStore.user!.userId);
const data = JSON.parse(json); const data = JSON.parse(json);
const ids: number[] = data.ids || data.data?.ids || []; const ids: number[] = data.ids || data.data?.ids || [];
likedIds.value = new Set(ids); likedIds.value = new Set(ids);
} catch { /* 忽略 */ } } catch (e) {
console.error('加载喜欢列表失败', e);
}
} }
async function toggleLike(songId: number) { async function toggleLike(songId: number) {
const wasLiked = likedIds.value.has(songId); const wasLiked = likedIds.value.has(songId);
const newLike = !wasLiked; const newLike = !wasLiked;
try { try {
await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } }); await MusicApi.likeSong(songId, newLike);
if (newLike) { if (newLike) {
likedIds.value.add(songId); likedIds.value.add(songId);
} else { } else {
@ -111,10 +105,6 @@ export const usePlayerStore = defineStore('player', () => {
localStorage.setItem('recent_local', JSON.stringify(val)); localStorage.setItem('recent_local', JSON.stringify(val));
}, { deep: true }); }, { deep: true });
watch(likedIds, (val) => {
localStorage.setItem('liked_ids', JSON.stringify([...val]));
}, { deep: true });
const isFmMode = ref(false); const isFmMode = ref(false);
const fmQueue: Song[] = []; const fmQueue: Song[] = [];
let fmNextCallback: (() => void) | null = null; let fmNextCallback: (() => void) | null = null;
@ -134,12 +124,10 @@ export const usePlayerStore = defineStore('player', () => {
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) { if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000); const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
if (playedSec > 5) { if (playedSec > 5) {
invoke('scrobble', { MusicApi.scrobble({
query: {
id: song.id, id: song.id,
sourceid: isFmMode.value ? String(song.id) : '', sourceid: isFmMode.value ? String(song.id) : '',
time: playedSec, time: playedSec,
},
}).catch(() => {}); }).catch(() => {});
} }
} }
@ -168,9 +156,10 @@ export const usePlayerStore = defineStore('player', () => {
async function fmTrash(songId: number) { async function fmTrash(songId: number) {
try { try {
await invoke('fm_trash', { query: { id: songId, time: 25 } }); await MusicApi.fmTrash(songId, 25);
} catch (e) { } catch (e) {
console.error('fm_trash 失败', e); console.error('fm_trash 失败', e);
showToast('减少推荐失败', 'error');
} }
await nextFm(); await nextFm();
} }
@ -178,13 +167,11 @@ export const usePlayerStore = defineStore('player', () => {
async function fetchFmBatch(): Promise<Song[]> { async function fetchFmBatch(): Promise<Song[]> {
const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value; const isDefault = fmMode.value === 'DEFAULT' && !fmSubMode.value;
const jsonStr: string = isDefault const jsonStr: string = isDefault
? await invoke('personal_fm') ? await MusicApi.personalFm()
: await invoke('personal_fm_mode', { : await MusicApi.personalFmMode({
query: {
mode: fmMode.value, mode: fmMode.value,
subMode: fmSubMode.value, subMode: fmSubMode.value,
limit: 3, limit: 3,
},
}); });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const raw = data.data || data; const raw = data.data || data;
@ -200,7 +187,7 @@ export const usePlayerStore = defineStore('player', () => {
reportScrobble(); reportScrobble();
if (!song.dt || song.dt === 0) { if (!song.dt || song.dt === 0) {
try { try {
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) }); const jsonStr = await MusicApi.getSongDetail(String(song.id));
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const full = data.songs?.[0]; const full = data.songs?.[0];
if (full) { if (full) {
@ -211,7 +198,7 @@ export const usePlayerStore = defineStore('player', () => {
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
} }
await invoke('stop_audio'); await AudioApi.stopAudio();
queue.value = []; queue.value = [];
currentIndex.value = -1; currentIndex.value = -1;
playing.value = false; playing.value = false;
@ -219,7 +206,7 @@ export const usePlayerStore = defineStore('player', () => {
fmSong.value = song; fmSong.value = song;
currentSong.value = song; currentSong.value = song;
try { try {
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality, fm_mode: true } }); const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality, fm_mode: true });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const url: string | undefined = data.url; const url: string | undefined = data.url;
if (!url) throw new Error('无播放源'); if (!url) throw new Error('无播放源');
@ -243,7 +230,7 @@ export const usePlayerStore = defineStore('player', () => {
} }
fmVipSkipCount = 0; fmVipSkipCount = 0;
await invoke('play_audio', { url }); await AudioApi.playAudio(url);
await waitForAudioStart(); await waitForAudioStart();
playing.value = true; playing.value = true;
duration.value = (song.dt || 0) / 1000; duration.value = (song.dt || 0) / 1000;
@ -254,6 +241,7 @@ export const usePlayerStore = defineStore('player', () => {
} catch (e) { } catch (e) {
console.error('FM播放失败', e); console.error('FM播放失败', e);
playing.value = false; playing.value = false;
showToast('FM 播放失败', 'error');
if (fmNextCallback) { if (fmNextCallback) {
fmNextCallback(); fmNextCallback();
} else { } else {
@ -268,7 +256,7 @@ export const usePlayerStore = defineStore('player', () => {
const idx = queue.value.findIndex(s => s.id === song.id); const idx = queue.value.findIndex(s => s.id === song.id);
if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) { if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) {
if (!playing.value) { if (!playing.value) {
await invoke('resume_audio'); await AudioApi.resumeAudio();
playing.value = true; playing.value = true;
startTick(); startTick();
} }
@ -294,7 +282,7 @@ export const usePlayerStore = defineStore('player', () => {
&& queue.value.every((s, i) => s.id === songs[i].id); && queue.value.every((s, i) => s.id === songs[i].id);
if (sameQueue) { if (sameQueue) {
if (!playing.value) { if (!playing.value) {
await invoke('resume_audio'); await AudioApi.resumeAudio();
playing.value = true; playing.value = true;
startTick(); startTick();
} }
@ -332,7 +320,7 @@ export const usePlayerStore = defineStore('player', () => {
duration.value = (song.dt || 0) / 1000; duration.value = (song.dt || 0) / 1000;
if (song.localPath) { if (song.localPath) {
await invoke('play_local_audio', { path: song.localPath }); await AudioApi.playLocalAudio(song.localPath);
await waitForAudioStart(); await waitForAudioStart();
playing.value = true; playing.value = true;
startTick(); startTick();
@ -341,7 +329,7 @@ export const usePlayerStore = defineStore('player', () => {
return; return;
} }
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } }); const jsonStr = await MusicApi.getSongUrl({ id: Number(song.id), level: settings.audioQuality });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const url: string | undefined = data.url; const url: string | undefined = data.url;
@ -363,7 +351,7 @@ export const usePlayerStore = defineStore('player', () => {
return; return;
} }
await invoke('play_audio', { url }); await AudioApi.playAudio(url);
await waitForAudioStart(); await waitForAudioStart();
playing.value = true; playing.value = true;
startTick(); startTick();
@ -373,6 +361,7 @@ export const usePlayerStore = defineStore('player', () => {
} catch (e) { } catch (e) {
console.error('播放失败', e); console.error('播放失败', e);
playing.value = false; playing.value = false;
showToast('播放失败,请稍后重试', 'error');
} }
} }
@ -392,7 +381,7 @@ export const usePlayerStore = defineStore('player', () => {
if (syncCounter >= 2) { if (syncCounter >= 2) {
syncCounter = 0; syncCounter = 0;
try { try {
const pos = await invoke<number>('get_audio_position'); const pos = await AudioApi.getAudioPosition();
if (pos >= currentTime.value - 0.5) { if (pos >= currentTime.value - 0.5) {
currentTime.value = pos; currentTime.value = pos;
} }
@ -423,17 +412,17 @@ export const usePlayerStore = defineStore('player', () => {
async function toggle() { async function toggle() {
if (playing.value) { if (playing.value) {
await invoke('pause_audio'); await AudioApi.pauseAudio();
playing.value = false; playing.value = false;
} else { } else {
await invoke('resume_audio'); await AudioApi.resumeAudio();
playing.value = true; playing.value = true;
} }
emitPlaybackState(); emitPlaybackState();
} }
async function stop() { async function stop() {
await invoke('stop_audio'); await AudioApi.stopAudio();
playing.value = false; playing.value = false;
currentSong.value = null; currentSong.value = null;
currentTime.value = 0; currentTime.value = 0;
@ -488,7 +477,7 @@ export const usePlayerStore = defineStore('player', () => {
try { try {
currentTime.value = time; currentTime.value = time;
if (onSeekStart) onSeekStart(); if (onSeekStart) onSeekStart();
await invoke('seek_audio', { time }); await AudioApi.seekAudio(time);
startTick(); startTick();
emitPlaybackState(); emitPlaybackState();
} catch (e) { } catch (e) {
@ -499,7 +488,7 @@ export const usePlayerStore = defineStore('player', () => {
async function adjustVolume(delta: number) { async function adjustVolume(delta: number) {
const newVol = Math.max(0, Math.min(100, volume.value + delta)); const newVol = Math.max(0, Math.min(100, volume.value + delta));
volume.value = newVol; volume.value = newVol;
await invoke('set_volume', { vol: newVol / 100 }); await AudioApi.setVolume(newVol / 100);
emitPlaybackState(); emitPlaybackState();
} }
@ -576,6 +565,7 @@ export const usePlayerStore = defineStore('player', () => {
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('FM 加载失败', 'error');
} }
return false; return false;
} }
@ -600,6 +590,7 @@ async function loadFm() {
} }
} catch (e) { } catch (e) {
console.error('FM加载失败', e); console.error('FM加载失败', e);
showToast('FM 加载失败', 'error');
} }
} }
@ -659,7 +650,7 @@ listen<string>('mpris-command', (event) => {
const vol = parseFloat(cmd.slice(10)); const vol = parseFloat(cmd.slice(10));
if (!isNaN(vol)) { if (!isNaN(vol)) {
player.volume = Math.round(vol * 100); player.volume = Math.round(vol * 100);
invoke('set_volume', { vol }).catch(() => {}); AudioApi.setVolume(vol).catch(() => {});
} }
} else if (cmd.startsWith('Seek:')) { } else if (cmd.startsWith('Seek:')) {
const offsetUs = parseInt(cmd.slice(5), 10); const offsetUs = parseInt(cmd.slice(5), 10);

View File

@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
export interface UserProfile { export interface UserProfile {
userId: number; userId: number;
@ -21,7 +21,7 @@ export const useUserStore = defineStore('user', () => {
} }
async function logout() { async function logout() {
try { await invoke('logout'); } catch { /* 忽略 */ } try { await MusicApi.logout(); } catch { /* 忽略 */ }
user.value = null; user.value = null;
isLoggedIn.value = false; isLoggedIn.value = false;
localStorage.removeItem('user_profile'); localStorage.removeItem('user_profile');

View File

@ -59,7 +59,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { normalizeSong, type Song } from '../utils/song'; import { normalizeSong, type Song } from '../utils/song';
import { formatDate } from '../utils/format'; import { formatDate } from '../utils/format';
@ -79,7 +79,7 @@ async function fetchAlbum(id: number) {
album.value = null; album.value = null;
songs.value = []; songs.value = [];
try { try {
const jsonStr: string = await invoke('album_detail', { id }); const jsonStr: string = await MusicApi.albumDetail(id);
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
album.value = data.album; album.value = data.album;
songs.value = (data.songs || []).map(normalizeSong); songs.value = (data.songs || []).map(normalizeSong);

View File

@ -83,7 +83,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue'; import { ref, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { formatPlayCount, formatDate } from '../utils/format'; import { formatPlayCount, formatDate } from '../utils/format';
import { normalizeSong, type Song } from '../utils/song'; import { normalizeSong, type Song } from '../utils/song';
@ -115,10 +115,10 @@ async function fetchArtist(id: number) {
briefDesc.value = ''; briefDesc.value = '';
try { try {
const [detailStr, songsStr, albumStr, descStr] = await Promise.all([ const [detailStr, songsStr, albumStr, descStr] = await Promise.all([
invoke('artist_detail', { id }) as Promise<string>, MusicApi.artistDetail(id),
invoke('artist_songs', { query: { id, order: 'hot', limit: 50, offset: 0 } }) as Promise<string>, MusicApi.artistSongs({ id, order: 'hot', limit: 50, offset: 0 }),
invoke('artist_album', { id, limit: 30, offset: 0 }) as Promise<string>, MusicApi.artistAlbum(id, 30, 0),
invoke('artist_desc', { id }) as Promise<string>, MusicApi.artistDesc(id),
]); ]);
const detailData = JSON.parse(detailStr); const detailData = JSON.parse(detailStr);
artist.value = detailData.artist; artist.value = detailData.artist;

View File

@ -36,7 +36,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onActivated, watch } from 'vue'; import { ref, onMounted, onActivated, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import SongListItem from '../components/SongListItem.vue'; import SongListItem from '../components/SongListItem.vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache'; import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
@ -63,7 +63,7 @@ async function loadData() {
} }
loading.value = true; loading.value = true;
try { try {
const jsonStr: string = await invoke('recommend_songs'); const jsonStr: string = await MusicApi.recommendSongs();
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
songs.value = (data.data?.dailySongs || []).map(normalizeSong); songs.value = (data.data?.dailySongs || []).map(normalizeSong);
pageCacheSet('dailySongs', songs.value); pageCacheSet('dailySongs', songs.value);

View File

@ -145,7 +145,7 @@ defineOptions({ name: 'DiscoverView' });
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue'; import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import SongListItem from '../components/SongListItem.vue'; import SongListItem from '../components/SongListItem.vue';
import { normalizeSong, type Song } from '../utils/song'; import { normalizeSong, type Song } from '../utils/song';
@ -231,7 +231,7 @@ function onInputChange() {
} }
suggestTimer = setTimeout(async () => { suggestTimer = setTimeout(async () => {
try { try {
const jsonStr: string = await invoke('search_suggest', { query: { keyword: keyword.value.trim() } }); const jsonStr: string = await MusicApi.searchSuggest(keyword.value.trim());
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const all = data.result?.allMatch || []; const all = data.result?.allMatch || [];
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8); suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
@ -254,7 +254,7 @@ async function loadHotTags() {
hotTags.value = cached; hotTags.value = cached;
} else { } else {
try { try {
const json = await invoke('get_hot_search'); const json = await MusicApi.getHotSearch();
const data = JSON.parse(json as string); const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12); hotTags.value = (data.data || []).slice(0, 12);
pageCacheSet('discover_hotTags', hotTags.value); pageCacheSet('discover_hotTags', hotTags.value);
@ -317,8 +317,8 @@ async function fetchTabResults(type: number) {
loading.value = true; loading.value = true;
cacheError.value = false; cacheError.value = false;
try { try {
const jsonStr: string = await invoke('cloudsearch', { const jsonStr: string = await MusicApi.cloudsearch({
query: { keyword: lastSearchKeyword.value, searchType: type, limit: 30 } keyword: lastSearchKeyword.value, searchType: type, limit: 30
}); });
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const result = data.result || {}; const result = data.result || {};

View File

@ -41,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onActivated, watch } from 'vue'; import { ref, onMounted, onActivated, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import SongListItem from '../components/SongListItem.vue'; import SongListItem from '../components/SongListItem.vue';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
@ -71,7 +71,7 @@ async function loadData() {
} }
loading.value = true; loading.value = true;
try { try {
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId }); const playlistJson: string = await MusicApi.userPlaylist(userStore.user!.userId);
const playlistData = JSON.parse(playlistJson); const playlistData = JSON.parse(playlistJson);
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed); const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
if (created.length === 0) { if (created.length === 0) {
@ -79,7 +79,7 @@ async function loadData() {
return; return;
} }
const likePlaylistId = created[0].id; const likePlaylistId = created[0].id;
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } }); const trackJson: string = await MusicApi.playlistTrackAll(likePlaylistId);
const trackData = JSON.parse(trackJson); const trackData = JSON.parse(trackJson);
songs.value = (trackData.songs || []).map(normalizeSong); songs.value = (trackData.songs || []).map(normalizeSong);
pageCacheSet('favoriteSongs', songs.value); pageCacheSet('favoriteSongs', songs.value);

View File

@ -104,7 +104,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onActivated, watch } from 'vue'; import { ref, onMounted, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache'; import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
@ -169,7 +169,7 @@ async function loadData() {
} }
const results = await Promise.allSettled( const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id })) RANK_IDS.map(id => MusicApi.getPlaylistDetail(id))
); );
rankPlaylists.value = results rankPlaylists.value = results
.filter(r => r.status === 'fulfilled') .filter(r => r.status === 'fulfilled')
@ -181,7 +181,7 @@ async function loadData() {
if (userStore.isLoggedIn) { if (userStore.isLoggedIn) {
try { try {
const json = await invoke('recommend_resource'); const json = await MusicApi.recommendResource();
const data = JSON.parse(json as string); const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || []; recPlaylists.value = data.recommend || [];
} catch { /* 忽略 */ } } catch { /* 忽略 */ }

View File

@ -76,7 +76,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'; import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi, DownloadApi } from '../api';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload'; import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
@ -129,7 +129,7 @@ async function refresh() {
loading.value = true; loading.value = true;
pageCacheInvalidate('localMusic'); pageCacheInvalidate('localMusic');
try { try {
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null }); const list = await DownloadApi.listLocalSongs(settings.downloadPath || null);
songs.value = list; songs.value = list;
pageCacheSet('localMusic', list); pageCacheSet('localMusic', list);
fetchMissingCovers(); fetchMissingCovers();
@ -145,7 +145,7 @@ async function fetchMissingCovers() {
if (missing.length === 0) return; if (missing.length === 0) return;
const ids = [...new Set(missing.map(s => s.id))]; const ids = [...new Set(missing.map(s => s.id))];
try { try {
const jsonStr: string = await invoke('get_song_detail', { id: JSON.stringify(ids) }); const jsonStr: string = await MusicApi.getSongDetail(JSON.stringify(ids));
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const detailMap = new Map<number, string>(); const detailMap = new Map<number, string>();
for (const s of data.songs || []) { for (const s of data.songs || []) {
@ -194,7 +194,7 @@ function confirmDelete(song: LocalSong) {
async function doDelete() { async function doDelete() {
if (!deleteTarget.value) return; if (!deleteTarget.value) return;
try { try {
await invoke('delete_local_song', { query: { id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null } }); await DownloadApi.deleteLocalSong({ id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null });
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id); songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
download.localSongIds.delete(deleteTarget.value.id); download.localSongIds.delete(deleteTarget.value.id);
showToast('已删除', 'success'); showToast('已删除', 'success');

View File

@ -20,7 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'; import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@ -52,7 +52,7 @@ async function refreshQr() {
qrError.value = ''; qrError.value = '';
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
try { try {
qrKey = await invoke('get_qr_key'); qrKey = await MusicApi.getQrKey();
if (!qrKey) { if (!qrKey) {
qrError.value = '未获取到登录密钥'; qrError.value = '未获取到登录密钥';
qrLoading.value = false; qrLoading.value = false;
@ -76,7 +76,7 @@ async function refreshQr() {
function startPolling() { function startPolling() {
pollTimer = setInterval(async () => { pollTimer = setInterval(async () => {
try { try {
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } }); const jsonStr: string = await MusicApi.checkQrStatus(qrKey);
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
const code = data.code; const code = data.code;
if (code === 800) { if (code === 800) {
@ -104,7 +104,7 @@ function startPolling() {
async function fetchUserProfile() { async function fetchUserProfile() {
try { try {
const profileJson: string = await invoke('get_login_status'); const profileJson: string = await MusicApi.getLoginStatus();
const profile = JSON.parse(profileJson); const profile = JSON.parse(profileJson);
if (profile.profile) { if (profile.profile) {
userStore.setUser({ userStore.setUser({

View File

@ -68,7 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core'; import { MusicApi } from '../api';
import { usePlayerStore } from '../stores/player'; import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { showToast } from '../composables/useToast'; import { showToast } from '../composables/useToast';
@ -98,7 +98,7 @@ async function fetchPlaylist(id: number) {
playlist.value = null; playlist.value = null;
songs.value = []; songs.value = [];
try { try {
const jsonStr: string = await invoke('get_playlist_detail', { id }); const jsonStr: string = await MusicApi.getPlaylistDetail(id);
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
playlist.value = data.playlist; playlist.value = data.playlist;
songs.value = (data.playlist.tracks || []).map(normalizeSong); songs.value = (data.playlist.tracks || []).map(normalizeSong);
@ -128,7 +128,7 @@ async function toggleSubscribe() {
if (!playlist.value) return; if (!playlist.value) return;
const newSubscribed = !subscribed.value; const newSubscribed = !subscribed.value;
try { try {
await invoke('playlist_subscribe', { query: { id: Number(playlist.value.id), subscribe: newSubscribed } }); await MusicApi.playlistSubscribe(Number(playlist.value.id), newSubscribed);
subscribed.value = newSubscribed; subscribed.value = newSubscribed;
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success'); showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
} catch { } catch {

View File

@ -258,7 +258,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings'; import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast'; import { useToast } from '../composables/useToast';
import { useUpdater } from '../composables/useUpdater'; import { useUpdater } from '../composables/useUpdater';
import { invoke } from '@tauri-apps/api/core'; import { DeviceApi, DownloadApi } from '../api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
@ -287,7 +287,7 @@ const selectedDevice = computed({
set: (val: string) => { set: (val: string) => {
const device = val === '' ? null : val; const device = val === '' ? null : val;
settings.setOutputDevice(device); settings.setOutputDevice(device);
invoke('set_output_device', { device }).then(() => { DeviceApi.setOutputDevice(device).then(() => {
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success'); showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
}).catch((e) => { }).catch((e) => {
console.error('切换设备失败: ', e); console.error('切换设备失败: ', e);
@ -298,7 +298,7 @@ const selectedDevice = computed({
async function loadDevices() { async function loadDevices() {
try { try {
devices.value = await invoke<string[]>('get_output_devices'); devices.value = await DeviceApi.getOutputDevices();
} catch (e) { } catch (e) {
console.error('获取设备失败: ', e); console.error('获取设备失败: ', e);
} }
@ -309,7 +309,7 @@ const defaultDownloadPath = ref('');
onMounted(async () => { onMounted(async () => {
appVersion.value = await getVersion(); appVersion.value = await getVersion();
try { try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path'); defaultDownloadPath.value = await DownloadApi.getDefaultDownloadPath();
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
loadDevices(); loadDevices();
}); });

View File

@ -4,11 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
import Icons from "unplugin-icons/vite"; import Icons from "unplugin-icons/vite";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
// \@ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [ plugins: [
vue(), vue(),
@ -21,11 +18,19 @@ export default defineConfig(async () => ({
}, },
}, },
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` build: {
// target: "esnext",
// 1. prevent Vite from obscuring rust errors cssCodeSplit: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ["vue", "vue-router", "pinia"],
},
},
},
},
clearScreen: false, clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
@ -38,7 +43,6 @@ export default defineConfig(async () => ({
} }
: undefined, : undefined,
watch: { watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
}, },