Repository: lyswhut/lx-music-desktop Branch: master Commit: 8d6b20783339 Files: 742 Total size: 3.1 MB Directory structure: gitextract_wcq2bc9m/ ├── .babelrc ├── .editorconfig ├── .eslintrc.base.cjs ├── .eslintrc.cjs ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ └── feature.yml │ ├── actions/ │ │ └── setup/ │ │ └── action.yml │ └── workflows/ │ ├── beta-pack.yml │ ├── build-test.yml │ ├── publish-version-info.yml │ └── release.yml ├── .gitignore ├── .ncurc.js ├── .vscode/ │ ├── i18n-ally-custom-framework.yml │ ├── javascript.code-snippets │ ├── settings.json │ └── typescript.code-snippets ├── CHANGELOG.md ├── FAQ.md ├── LICENSE ├── README.md ├── build-config/ │ ├── build-after-pack.js │ ├── build-before-pack.js │ ├── build-pack.js │ ├── css-loader.config.js │ ├── dependencies-patch.js │ ├── lib/ │ │ ├── better_sqlite3_electron-v136-linux-arm.node │ │ ├── better_sqlite3_electron-v136-linux-arm64.node │ │ ├── better_sqlite3_electron-v136-linux-x64.node │ │ ├── qrc_decode_electron-v110-win32-arm64.node │ │ ├── qrc_decode_electron-v110-win32-ia32.node │ │ ├── qrc_decode_electron-v110-win32-x64.node │ │ ├── qrc_decode_electron-v136-darwin-arm64.node │ │ ├── qrc_decode_electron-v136-darwin-x64.node │ │ ├── qrc_decode_electron-v136-linux-arm.node │ │ ├── qrc_decode_electron-v136-linux-arm64.node │ │ ├── qrc_decode_electron-v136-linux-x64.node │ │ ├── qrc_decode_electron-v136-win32-arm64.node │ │ ├── qrc_decode_electron-v136-win32-ia32.node │ │ └── qrc_decode_electron-v136-win32-x64.node │ ├── lib-update.js │ ├── main/ │ │ ├── webpack.config.base.js │ │ ├── webpack.config.dev.js │ │ └── webpack.config.prod.js │ ├── pack.js │ ├── post-install.js │ ├── renderer/ │ │ ├── webpack.config.base.js │ │ ├── webpack.config.dev.js │ │ └── webpack.config.prod.js │ ├── renderer-lyric/ │ │ ├── webpack.config.base.js │ │ ├── webpack.config.dev.js │ │ └── webpack.config.prod.js │ ├── renderer-scripts/ │ │ ├── webpack.config.base.js │ │ ├── webpack.config.dev.js │ │ └── webpack.config.prod.js │ ├── runner-dev.js │ ├── utils.js │ ├── vue-loader.config.js │ └── webpack-build-config.js ├── jsconfig.json ├── licenses/ │ ├── license.rtf │ ├── license_en.txt │ └── license_zh.txt ├── package.json ├── postcss.config.js ├── publish/ │ ├── changeLog.md │ ├── index.js │ ├── utils/ │ │ ├── clearAssets.js │ │ ├── compileAssets.js │ │ ├── copyFile.js │ │ ├── cos.js │ │ ├── cosConfig.js │ │ ├── githubRelease.js │ │ ├── index.js │ │ ├── packAssets.js │ │ ├── parseChangelog.js │ │ └── updateChangeLog.js │ └── version.json ├── resources/ │ └── icons/ │ └── icon.icns ├── src/ │ ├── common/ │ │ ├── .eslintrc.cjs │ │ ├── config.ts │ │ ├── constants.ts │ │ ├── constants_sync.ts │ │ ├── defaultHotKey.ts │ │ ├── defaultSetting.ts │ │ ├── error.ts │ │ ├── hotKey.ts │ │ ├── ipcNames.ts │ │ ├── mainIpc.ts │ │ ├── rendererIpc.ts │ │ ├── theme/ │ │ │ ├── colorUtils.js │ │ │ ├── createThemes.js │ │ │ ├── index.json │ │ │ └── utils.js │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── app_setting.d.ts │ │ │ ├── common.d.ts │ │ │ ├── config_files.d.ts │ │ │ ├── desktop_lyric.d.ts │ │ │ ├── dislike_list.d.ts │ │ │ ├── dislike_list_sync.d.ts │ │ │ ├── download_list.d.ts │ │ │ ├── ipc_main.d.ts │ │ │ ├── ipc_renderer.d.ts │ │ │ ├── list.d.ts │ │ │ ├── list_sync.d.ts │ │ │ ├── music.d.ts │ │ │ ├── music_metadata.d.ts │ │ │ ├── open_api.d.ts │ │ │ ├── player.d.ts │ │ │ ├── shims_vue.d.ts │ │ │ ├── sound_effect.d.ts │ │ │ ├── sync.d.ts │ │ │ ├── theme.d.ts │ │ │ ├── user_api.d.ts │ │ │ └── utils.d.ts │ │ └── utils/ │ │ ├── common.ts │ │ ├── download/ │ │ │ ├── Downloader.ts │ │ │ ├── index.ts │ │ │ ├── request.ts │ │ │ └── util.ts │ │ ├── effects/ │ │ │ └── cursor-effects/ │ │ │ └── bubbleCursor.js │ │ ├── electron.ts │ │ ├── index.ts │ │ ├── lyric-font-player/ │ │ │ ├── font-player.js │ │ │ ├── index.js │ │ │ ├── line-player.js │ │ │ └── utils.js │ │ ├── lyricUtils/ │ │ │ ├── kg.js │ │ │ └── util.ts │ │ ├── migrateSetting.ts │ │ ├── musicMeta/ │ │ │ ├── downloader.js │ │ │ ├── flac-metadata/ │ │ │ │ ├── index.js │ │ │ │ └── lib/ │ │ │ │ ├── MetaDataBlock.js │ │ │ │ ├── MetaDataBlockPicture.js │ │ │ │ ├── MetaDataBlockStreamInfo.js │ │ │ │ └── MetaDataBlockVorbisComment.js │ │ │ ├── flacMeta.js │ │ │ ├── index.d.ts │ │ │ ├── index.js │ │ │ └── mp3Meta.js │ │ ├── nodejs.ts │ │ ├── pinyin/ │ │ │ ├── kMandarin_8105.txt │ │ │ ├── parser.js │ │ │ └── pinyin.json │ │ ├── renderer.ts │ │ ├── request.ts │ │ ├── request_node16.ts │ │ ├── tools.ts │ │ ├── vueRouter.ts │ │ └── vueTools.ts │ ├── lang/ │ │ ├── .eslintrc.cjs │ │ ├── Readme.md │ │ ├── en-us.json │ │ ├── i18n.ts │ │ ├── index.ts │ │ ├── languages.json │ │ ├── tsconfig.json │ │ ├── zh-cn.json │ │ └── zh-tw.json │ ├── main/ │ │ ├── .eslintrc.cjs │ │ ├── app.ts │ │ ├── event/ │ │ │ ├── AppEvent.ts │ │ │ ├── DislikeEvent.ts │ │ │ ├── ListEvent.ts │ │ │ └── index.ts │ │ ├── index-dev.ts │ │ ├── index.ts │ │ ├── modules/ │ │ │ ├── appMenu.ts │ │ │ ├── commonRenderers/ │ │ │ │ ├── common/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rendererEvent.ts │ │ │ │ │ └── winRendererEvent.ts │ │ │ │ ├── dislike/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rendererEvent.ts │ │ │ │ │ └── winRendererEvent.ts │ │ │ │ ├── index.ts │ │ │ │ └── list/ │ │ │ │ ├── index.ts │ │ │ │ ├── rendererEvent.ts │ │ │ │ └── winRendererEvent.ts │ │ │ ├── hotKey/ │ │ │ │ ├── index.ts │ │ │ │ ├── rendererEvent.ts │ │ │ │ └── utils.ts │ │ │ ├── index.ts │ │ │ ├── openApi/ │ │ │ │ └── index.ts │ │ │ ├── sync/ │ │ │ │ ├── client/ │ │ │ │ │ ├── auth.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── data.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── dislike/ │ │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── localEvent.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── list/ │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── localEvent.ts │ │ │ │ │ ├── sync/ │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── dislikeEvent.ts │ │ │ │ ├── index.ts │ │ │ │ ├── listEvent.ts │ │ │ │ ├── log.ts │ │ │ │ ├── migrate.ts │ │ │ │ ├── server/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── modules/ │ │ │ │ │ │ ├── dislike/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── manage.ts │ │ │ │ │ │ │ ├── snapshotDataManage.ts │ │ │ │ │ │ │ ├── sync/ │ │ │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── localEvent.ts │ │ │ │ │ │ │ │ └── sync.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── list/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── manage.ts │ │ │ │ │ │ ├── snapshotDataManage.ts │ │ │ │ │ │ └── sync/ │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── localEvent.ts │ │ │ │ │ │ └── sync.ts │ │ │ │ │ ├── server/ │ │ │ │ │ │ ├── auth.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── server.ts │ │ │ │ │ │ └── sync/ │ │ │ │ │ │ ├── event.ts │ │ │ │ │ │ ├── handler.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── sync.ts │ │ │ │ │ ├── user/ │ │ │ │ │ │ ├── data.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tools.ts │ │ │ │ └── utils.ts │ │ │ ├── tray.ts │ │ │ ├── userApi/ │ │ │ │ ├── config/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── main.ts │ │ │ │ ├── renderer/ │ │ │ │ │ ├── preload.js │ │ │ │ │ └── user-api.html │ │ │ │ ├── rendererEvent/ │ │ │ │ │ ├── name.js │ │ │ │ │ └── rendererEvent.ts │ │ │ │ └── utils.ts │ │ │ ├── winLyric/ │ │ │ │ ├── config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── main.ts │ │ │ │ ├── rendererEvent.ts │ │ │ │ └── utils.ts │ │ │ └── winMain/ │ │ │ ├── autoUpdate.ts │ │ │ ├── index.ts │ │ │ ├── main.ts │ │ │ ├── rendererEvent/ │ │ │ │ ├── app.ts │ │ │ │ ├── data.ts │ │ │ │ ├── download.ts │ │ │ │ ├── hotKey.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kw_decodeLyric.ts │ │ │ │ ├── music.ts │ │ │ │ ├── openAPI.ts │ │ │ │ ├── process.ts │ │ │ │ ├── soundEffect.ts │ │ │ │ ├── sync.ts │ │ │ │ ├── tx_decodeLyric.ts │ │ │ │ └── userApi.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── app.d.ts │ │ │ ├── common.d.ts │ │ │ ├── db_service.d.ts │ │ │ ├── global.d.ts │ │ │ ├── sync.d.ts │ │ │ ├── sync_common.d.ts │ │ │ └── worker.d.ts │ │ ├── utils/ │ │ │ ├── fontManage.ts │ │ │ ├── index.ts │ │ │ ├── logInit.ts │ │ │ ├── migrate.ts │ │ │ ├── request.ts │ │ │ └── store.ts │ │ └── worker/ │ │ ├── dbService/ │ │ │ ├── db.ts │ │ │ ├── index.ts │ │ │ ├── migrate.ts │ │ │ ├── modules/ │ │ │ │ ├── dislike_list/ │ │ │ │ │ ├── dbHelper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── statements.ts │ │ │ │ ├── download/ │ │ │ │ │ ├── dbHelper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── statements.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list/ │ │ │ │ │ ├── dbHelper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── statements.ts │ │ │ │ ├── lyric/ │ │ │ │ │ ├── dbHelper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── statements.ts │ │ │ │ ├── music_other_source/ │ │ │ │ │ ├── dbHelper.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── statements.ts │ │ │ │ └── music_url/ │ │ │ │ ├── dbHelper.ts │ │ │ │ ├── index.ts │ │ │ │ └── statements.ts │ │ │ ├── tables.ts │ │ │ └── verifyDB.ts │ │ ├── index.ts │ │ └── utils/ │ │ ├── index.ts │ │ └── worker.ts │ ├── renderer/ │ │ ├── .eslintrc.cjs │ │ ├── App.vue │ │ ├── assets/ │ │ │ └── styles/ │ │ │ ├── animate.less │ │ │ ├── colors.less │ │ │ ├── index.less │ │ │ ├── layout.less │ │ │ ├── reset.less │ │ │ └── variables.less │ │ ├── components/ │ │ │ ├── base/ │ │ │ │ ├── Btn.vue │ │ │ │ ├── Checkbox.vue │ │ │ │ ├── Input.vue │ │ │ │ ├── Menu.vue │ │ │ │ ├── MusicList.vue │ │ │ │ ├── Popup.vue │ │ │ │ ├── Selection.vue │ │ │ │ ├── SliderBar.vue │ │ │ │ ├── Tab.vue │ │ │ │ ├── VirtualizedList.vue │ │ │ │ └── useVirtualizedList.ts │ │ │ ├── common/ │ │ │ │ ├── AudioVisualizer.vue │ │ │ │ ├── DownloadModal.vue │ │ │ │ ├── DownloadMultipleModal.vue │ │ │ │ ├── ListAddModal.vue │ │ │ │ ├── ListAddMultipleModal.vue │ │ │ │ ├── PlaybackRateBtn.vue │ │ │ │ ├── ProgressBar.vue │ │ │ │ ├── SoundEffectBtn/ │ │ │ │ │ ├── AddConvolutionPresetBtn.vue │ │ │ │ │ ├── AddEQPresetBtn.vue │ │ │ │ │ ├── AudioConvolution.vue │ │ │ │ │ ├── AudioPanner.vue │ │ │ │ │ ├── BiquadFilter.vue │ │ │ │ │ ├── PitchShifter.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── TogglePlayModeBtn.vue │ │ │ │ └── VolumeBtn.vue │ │ │ ├── index.js │ │ │ ├── layout/ │ │ │ │ ├── Aside/ │ │ │ │ │ ├── ControlBtns.vue │ │ │ │ │ ├── NavBar.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── ChangeLogModal.vue │ │ │ │ ├── Icons.vue │ │ │ │ ├── PactModal.vue │ │ │ │ ├── PlayBar/ │ │ │ │ │ ├── ControlBtns.vue │ │ │ │ │ ├── FullWidthProgress.vue │ │ │ │ │ ├── MiddleWidthProgress.vue │ │ │ │ │ ├── MiniWidthProgress.vue │ │ │ │ │ ├── PlayProgress.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── PlayDetail/ │ │ │ │ │ ├── ControlBtnsLeftHeader.vue │ │ │ │ │ ├── ControlBtnsRightHeader.vue │ │ │ │ │ ├── LyricPlayer.vue │ │ │ │ │ ├── PlayBar.vue │ │ │ │ │ ├── autoHideMounse.js │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ControlBtns.vue │ │ │ │ │ │ ├── LyricMenu.vue │ │ │ │ │ │ └── MusicComment/ │ │ │ │ │ │ ├── CommentFloor.vue │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ └── useSelectAllLrc.js │ │ │ │ ├── SyncAuthCodeModal.vue │ │ │ │ ├── SyncModeModal.vue │ │ │ │ ├── Toolbar/ │ │ │ │ │ ├── ControlBtns.vue │ │ │ │ │ ├── SearchInput.vue │ │ │ │ │ └── index.vue │ │ │ │ ├── UpdateModal.vue │ │ │ │ └── View.vue │ │ │ └── material/ │ │ │ ├── ListButtons.vue │ │ │ ├── Modal.vue │ │ │ ├── OnlineList/ │ │ │ │ ├── index.vue │ │ │ │ ├── useList.ts │ │ │ │ ├── useMenu.js │ │ │ │ ├── useMusicActions.js │ │ │ │ ├── useMusicAdd.js │ │ │ │ ├── useMusicDownload.js │ │ │ │ └── usePlay.ts │ │ │ ├── Pagination.vue │ │ │ ├── PopupBtn.vue │ │ │ ├── SearchInput.vue │ │ │ └── SongList.vue │ │ ├── core/ │ │ │ ├── apiSource.ts │ │ │ ├── dislikeList.ts │ │ │ ├── globalData.ts │ │ │ ├── lyric.ts │ │ │ ├── music/ │ │ │ │ ├── download.ts │ │ │ │ ├── index.ts │ │ │ │ ├── local.ts │ │ │ │ ├── online.ts │ │ │ │ └── utils.ts │ │ │ ├── player/ │ │ │ │ ├── action.ts │ │ │ │ ├── index.ts │ │ │ │ ├── timeoutStop.ts │ │ │ │ └── utils.ts │ │ │ └── useApp/ │ │ │ ├── compositions/ │ │ │ │ └── usePlaySonglist.ts │ │ │ ├── index.ts │ │ │ ├── listAutoUpdate.ts │ │ │ ├── useDataInit.ts │ │ │ ├── useDeeplink/ │ │ │ │ ├── index.ts │ │ │ │ ├── useMusicAction.js │ │ │ │ ├── usePlayerAction.ts │ │ │ │ ├── useSonglistAction.js │ │ │ │ └── utils.js │ │ │ ├── useEventListener.ts │ │ │ ├── useHandleEnvParams.ts │ │ │ ├── useInitUserApi.ts │ │ │ ├── useOpenAPI.ts │ │ │ ├── usePlayer/ │ │ │ │ ├── index.ts │ │ │ │ ├── useLyric.ts │ │ │ │ ├── useMaxOutputChannelCount.ts │ │ │ │ ├── useMediaDevice.ts │ │ │ │ ├── useMediaSessionInfo.ts │ │ │ │ ├── usePlayEvent.ts │ │ │ │ ├── usePlayProgress.ts │ │ │ │ ├── usePlayStatus.ts │ │ │ │ ├── usePlaybackRate.ts │ │ │ │ ├── usePlayer.ts │ │ │ │ ├── usePlayerEvent.ts │ │ │ │ ├── usePreloadNextMusic.ts │ │ │ │ ├── useSoundEffect.ts │ │ │ │ ├── useVolume.ts │ │ │ │ └── useWatchList.ts │ │ │ ├── useSettingSync.ts │ │ │ ├── useStatusbarLyric.ts │ │ │ ├── useSync.ts │ │ │ └── useUpdate.ts │ │ ├── event/ │ │ │ ├── Event.ts │ │ │ ├── appEvent.ts │ │ │ ├── index.ts │ │ │ └── keyEvent.ts │ │ ├── index.html │ │ ├── main.ts │ │ ├── plugins/ │ │ │ ├── Dialog/ │ │ │ │ ├── Dialog.vue │ │ │ │ └── index.js │ │ │ ├── SvgIcon/ │ │ │ │ ├── SvgIcon.vue │ │ │ │ └── index.js │ │ │ ├── Tips/ │ │ │ │ ├── Tips.js │ │ │ │ ├── Tips.vue │ │ │ │ └── index.js │ │ │ ├── i18n.ts │ │ │ ├── index.ts │ │ │ └── player/ │ │ │ ├── index.ts │ │ │ └── pitch-shifter/ │ │ │ ├── fft.js │ │ │ ├── ola-processor.js │ │ │ └── phase-vocoder.js │ │ ├── router.ts │ │ ├── store/ │ │ │ ├── dislikeList/ │ │ │ │ ├── action.ts │ │ │ │ ├── index.ts │ │ │ │ └── state.ts │ │ │ ├── download/ │ │ │ │ ├── action.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── hotSearch.ts │ │ │ ├── index.ts │ │ │ ├── leaderboard/ │ │ │ │ ├── action.ts │ │ │ │ └── state.ts │ │ │ ├── list/ │ │ │ │ ├── action.ts │ │ │ │ ├── listManage/ │ │ │ │ │ ├── action.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rendererListManage.ts │ │ │ │ │ └── state.ts │ │ │ │ ├── state.ts │ │ │ │ └── syncSourceList.ts │ │ │ ├── player/ │ │ │ │ ├── action.ts │ │ │ │ ├── lyric.ts │ │ │ │ ├── playProgress.ts │ │ │ │ ├── playbackRate.ts │ │ │ │ ├── state.ts │ │ │ │ └── volume.ts │ │ │ ├── search/ │ │ │ │ ├── action.ts │ │ │ │ ├── music/ │ │ │ │ │ ├── action.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── state.ts │ │ │ │ ├── songlist/ │ │ │ │ │ ├── action.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── state.ts │ │ │ │ └── state.ts │ │ │ ├── setting.ts │ │ │ ├── songList/ │ │ │ │ ├── action.ts │ │ │ │ └── state.ts │ │ │ ├── soundEffect.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── types/ │ │ │ ├── app.d.ts │ │ │ ├── common.d.ts │ │ │ ├── i18n.d.ts │ │ │ ├── player.d.ts │ │ │ └── worker.d.ts │ │ ├── utils/ │ │ │ ├── compositions/ │ │ │ │ ├── useDrag.js │ │ │ │ ├── useIconSize.ts │ │ │ │ ├── useImportTip.js │ │ │ │ ├── useKeyDown.ts │ │ │ │ ├── useLyric.js │ │ │ │ ├── useMenuLocation.js │ │ │ │ ├── useNextTogglePlay.ts │ │ │ │ ├── usePlayProgress.js │ │ │ │ └── useToggleDesktopLyric.js │ │ │ ├── data.ts │ │ │ ├── env.js │ │ │ ├── index.ts │ │ │ ├── ipc.ts │ │ │ ├── keyBind.ts │ │ │ ├── message.ts │ │ │ ├── music.ts │ │ │ ├── musicSdk/ │ │ │ │ ├── api-source-info.ts │ │ │ │ ├── api-source.js │ │ │ │ ├── bd/ │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── musicInfo.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ └── songList.js │ │ │ │ ├── index.js │ │ │ │ ├── kg/ │ │ │ │ │ ├── album.js │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── comment.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── lyric.js │ │ │ │ │ ├── musicInfo.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ ├── pic.js │ │ │ │ │ ├── singer.js │ │ │ │ │ ├── songList.js │ │ │ │ │ ├── temp/ │ │ │ │ │ │ ├── musicSearch-new.js │ │ │ │ │ │ └── songList-new.js │ │ │ │ │ ├── tipSearch.js │ │ │ │ │ └── util.js │ │ │ │ ├── kw/ │ │ │ │ │ ├── album.js │ │ │ │ │ ├── api-temp.js │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── comment.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── lyric.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ ├── pic.js │ │ │ │ │ ├── songList.js │ │ │ │ │ ├── tipSearch.js │ │ │ │ │ └── util.js │ │ │ │ ├── mg/ │ │ │ │ │ ├── album.js │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── comment.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── lyric.js │ │ │ │ │ ├── musicInfo.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ ├── pic.js │ │ │ │ │ ├── songId.js │ │ │ │ │ ├── songList.js │ │ │ │ │ ├── temp/ │ │ │ │ │ │ └── leaderboard-old.js │ │ │ │ │ ├── tipSearch.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── mrc.js │ │ │ │ ├── options.js │ │ │ │ ├── tx/ │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── comment.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── lyric.js │ │ │ │ │ ├── musicInfo.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ ├── singer.js │ │ │ │ │ ├── songList.js │ │ │ │ │ └── tipSearch.js │ │ │ │ ├── utils.js │ │ │ │ ├── wy/ │ │ │ │ │ ├── api-test.js │ │ │ │ │ ├── comment.js │ │ │ │ │ ├── hotSearch.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── leaderboard.js │ │ │ │ │ ├── lyric.js │ │ │ │ │ ├── musicDetail.js │ │ │ │ │ ├── musicInfo.js │ │ │ │ │ ├── musicSearch.js │ │ │ │ │ ├── singer.js │ │ │ │ │ ├── songList.js │ │ │ │ │ ├── tipSearch.js │ │ │ │ │ └── utils/ │ │ │ │ │ ├── crypto.js │ │ │ │ │ └── index.js │ │ │ │ └── xm.js │ │ │ ├── pickrTools.ts │ │ │ ├── request.js │ │ │ ├── simplify-chinese-main/ │ │ │ │ ├── LICENSE.md │ │ │ │ ├── README.md │ │ │ │ ├── chinese.js │ │ │ │ ├── index.d.ts │ │ │ │ └── index.js │ │ │ └── update.js │ │ ├── views/ │ │ │ ├── Download/ │ │ │ │ ├── index.vue │ │ │ │ ├── useList.js │ │ │ │ ├── useListInfo.js │ │ │ │ ├── useMenu.js │ │ │ │ ├── useMusicAdd.js │ │ │ │ ├── usePlay.js │ │ │ │ ├── useTab.js │ │ │ │ └── useTaskActions.js │ │ │ ├── Leaderboard/ │ │ │ │ ├── BoardList/ │ │ │ │ │ ├── index.vue │ │ │ │ │ └── useMenu.js │ │ │ │ ├── MusicList/ │ │ │ │ │ ├── index.vue │ │ │ │ │ └── useList.ts │ │ │ │ ├── action.ts │ │ │ │ └── index.vue │ │ │ ├── List/ │ │ │ │ ├── MusicList/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── MusicSortModal.vue │ │ │ │ │ │ ├── MusicToggleModal.vue │ │ │ │ │ │ └── SearchList.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ ├── useList.js │ │ │ │ │ ├── useListInfo.js │ │ │ │ │ ├── useListScroll.js │ │ │ │ │ ├── useMenu.js │ │ │ │ │ ├── useMusicActions.js │ │ │ │ │ ├── useMusicAdd.js │ │ │ │ │ ├── useMusicDownload.js │ │ │ │ │ ├── useMusicToggle.js │ │ │ │ │ ├── usePlay.js │ │ │ │ │ ├── useSearch.js │ │ │ │ │ └── useSort.js │ │ │ │ ├── MyList/ │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── DuplicateMusicModal.vue │ │ │ │ │ │ ├── ListSortModal.vue │ │ │ │ │ │ ├── ListUpdateModal.vue │ │ │ │ │ │ └── MusicSortModal.vue │ │ │ │ │ ├── index.vue │ │ │ │ │ ├── useDarg.ts │ │ │ │ │ ├── useDuplicate.ts │ │ │ │ │ ├── useEditList.ts │ │ │ │ │ ├── useListScroll.ts │ │ │ │ │ ├── useListUpdate.ts │ │ │ │ │ ├── useMenu.js │ │ │ │ │ ├── useShare.ts │ │ │ │ │ └── useSort.js │ │ │ │ └── index.vue │ │ │ ├── Search/ │ │ │ │ ├── MusicList/ │ │ │ │ │ ├── index.vue │ │ │ │ │ └── useList.ts │ │ │ │ ├── SongListList/ │ │ │ │ │ ├── index.vue │ │ │ │ │ └── useList.ts │ │ │ │ ├── components/ │ │ │ │ │ └── BlankView.vue │ │ │ │ └── index.vue │ │ │ ├── Setting/ │ │ │ │ ├── components/ │ │ │ │ │ ├── DislikeListModal.vue │ │ │ │ │ ├── PlayTimeoutModal.vue │ │ │ │ │ ├── SettingAbout.vue │ │ │ │ │ ├── SettingBackup.vue │ │ │ │ │ ├── SettingBasic.vue │ │ │ │ │ ├── SettingDesktopLyric.vue │ │ │ │ │ ├── SettingDownload.vue │ │ │ │ │ ├── SettingHotKey.vue │ │ │ │ │ ├── SettingList.vue │ │ │ │ │ ├── SettingNetwork.vue │ │ │ │ │ ├── SettingOdc.vue │ │ │ │ │ ├── SettingOpenAPI.vue │ │ │ │ │ ├── SettingOther.vue │ │ │ │ │ ├── SettingPlay.vue │ │ │ │ │ ├── SettingPlayDetail.vue │ │ │ │ │ ├── SettingSearch.vue │ │ │ │ │ ├── SettingSync/ │ │ │ │ │ │ ├── ServerDeviceListModal.vue │ │ │ │ │ │ ├── SyncClient.vue │ │ │ │ │ │ ├── SyncServer.vue │ │ │ │ │ │ └── index.vue │ │ │ │ │ ├── SettingUpdate.vue │ │ │ │ │ ├── ThemeEditModal/ │ │ │ │ │ │ ├── index.vue │ │ │ │ │ │ ├── useAppBgColor.ts │ │ │ │ │ │ ├── useAsideFontColor.ts │ │ │ │ │ │ ├── useBadgePrimaryColor.ts │ │ │ │ │ │ ├── useBadgeSecondaryColor.ts │ │ │ │ │ │ ├── useBadgeTertiaryColor.ts │ │ │ │ │ │ ├── useCloseBtnColor.ts │ │ │ │ │ │ ├── useFontColor.ts │ │ │ │ │ │ ├── useHideBtnColor.ts │ │ │ │ │ │ ├── useMainBgColor.ts │ │ │ │ │ │ ├── useMainColor.ts │ │ │ │ │ │ └── useMinBtnColor.ts │ │ │ │ │ ├── ThemeSelectorModal.vue │ │ │ │ │ ├── UserApiModal.vue │ │ │ │ │ └── UserApiOnlineImportModal.vue │ │ │ │ └── index.vue │ │ │ └── songList/ │ │ │ ├── Detail/ │ │ │ │ ├── action.ts │ │ │ │ ├── index.vue │ │ │ │ ├── useKeyBack.ts │ │ │ │ └── useList.ts │ │ │ └── List/ │ │ │ ├── ListView.vue │ │ │ ├── components/ │ │ │ │ ├── OpenListModal.vue │ │ │ │ ├── SongList.vue │ │ │ │ ├── SortTab.vue │ │ │ │ └── TagList.vue │ │ │ └── index.vue │ │ └── worker/ │ │ ├── download/ │ │ │ ├── common.ts │ │ │ ├── download.ts │ │ │ ├── index.ts │ │ │ ├── lrcTool.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── main/ │ │ │ ├── common.ts │ │ │ ├── index.ts │ │ │ ├── list.ts │ │ │ └── music.ts │ │ └── utils/ │ │ ├── index.ts │ │ └── worker.ts │ └── renderer-lyric/ │ ├── .eslintrc.cjs │ ├── App.vue │ ├── assets/ │ │ └── styles/ │ │ ├── animate.less │ │ ├── colors.less │ │ ├── index.less │ │ ├── layout.less │ │ ├── reset.less │ │ └── variables.less │ ├── components/ │ │ ├── common/ │ │ │ └── AudioVisualizer.vue │ │ ├── index.js │ │ └── layout/ │ │ ├── ControlBar.vue │ │ ├── Icons.vue │ │ ├── LyricHorizontal/ │ │ │ ├── index.vue │ │ │ └── useLyric.js │ │ ├── LyricVertical/ │ │ │ ├── index.vue │ │ │ └── useLyric.js │ │ └── useDrag.js │ ├── core/ │ │ ├── lyric.ts │ │ └── mainWindowChannel.ts │ ├── index.html │ ├── main.ts │ ├── plugins/ │ │ └── i18n.ts │ ├── store/ │ │ ├── action.ts │ │ ├── lyric.ts │ │ └── state.ts │ ├── tsconfig.json │ ├── types/ │ │ ├── app.d.ts │ │ └── common.d.ts │ ├── useApp/ │ │ ├── useCommon.ts │ │ ├── useHoverHide.ts │ │ ├── useLyric.ts │ │ ├── usePauseHide.ts │ │ ├── useTheme.ts │ │ └── useWindowSize.ts │ └── utils/ │ └── ipc.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ "@babel/preset-typescript", [ "@babel/preset-env", { "corejs": "3", "useBuiltIns": "usage" } ] // [ // "minify", // { // "builtIns": false, // "evaluate": false, // "mangle": false // } // ] ], "plugins": [ "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-modules-umd", "@babel/plugin-transform-runtime", "@babel/plugin-transform-class-properties" ] } ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .eslintrc.base.cjs ================================================ const baseRule = { 'no-new': 'off', camelcase: 'off', 'no-return-assign': 'off', 'space-before-function-paren': ['error', 'never'], 'no-var': 'error', 'no-fallthrough': 'off', eqeqeq: 'off', 'require-atomic-updates': ['error', { allowProperties: true }], 'no-multiple-empty-lines': [1, { max: 2 }], 'comma-dangle': [2, 'always-multiline'], 'standard/no-callback-literal': 'off', 'prefer-const': 'off', 'no-labels': 'off', 'node/no-callback-literal': 'off', 'multiline-ternary': 'off', } const typescriptRule = { ...baseRule, '@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/space-before-function-paren': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/restrict-template-expressions': [1, { allowBoolean: true, allowAny: true, }], '@typescript-eslint/restrict-plus-operands': [1, { allowBoolean: true, allowAny: true, }], '@typescript-eslint/no-misused-promises': [ 'error', { checksVoidReturn: { arguments: false, attributes: false, }, }, ], '@typescript-eslint/naming-convention': 'off', '@typescript-eslint/return-await': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/comma-dangle': 'off', '@typescript-eslint/no-unsafe-argument': 'off', } const vueRule = { ...typescriptRule, 'vue/multi-word-component-names': 'off', 'vue/max-attributes-per-line': 'off', 'vue/singleline-html-element-content-newline': 'off', 'vue/use-v-on-exact': 'off', } exports.base = { extends: ['standard'], rules: baseRule, parser: '@babel/eslint-parser', } exports.html = { files: ['*.html'], plugins: ['html'], } exports.typescript = { files: ['*.ts'], rules: typescriptRule, parser: '@typescript-eslint/parser', extends: [ 'standard-with-typescript', ], } exports.vue = { files: ['*.vue'], rules: vueRule, parser: 'vue-eslint-parser', extends: [ // 'plugin:vue/vue3-essential', 'plugin:vue/base', 'plugin:vue/vue3-recommended', 'plugin:vue-pug/vue3-recommended', // "plugin:vue/strongly-recommended" 'standard-with-typescript', ], parserOptions: { sourceType: 'module', parser: { // Script parser for ` // // ` // break case '/lyric': msg = global.lx.player_status.lyric break case '/lyric-all': handleSendAllLyric(res) return case '/play': sendTaskbarButtonClick('play') break case '/pause': sendTaskbarButtonClick('pause') break case '/skip-next': sendTaskbarButtonClick('next') break case '/skip-prev': sendTaskbarButtonClick('prev') break case '/seek': { const offset = parseFloat(querystring.parse(query ?? '').offset as string) if (Number.isNaN(offset) || offset < 0 || offset > global.lx.player_status.duration) { code = 400 msg = 'Invalid offset' } else { sendTaskbarButtonClick('seek', parseFloat(offset.toFixed(3))) } break } case '/collect': sendTaskbarButtonClick('collect') break case '/uncollect': sendTaskbarButtonClick('unCollect') break case '/volume': { const volume = parseInt(querystring.parse(query ?? '').volume as string) if (Number.isNaN(volume) || volume < 0 || volume > 100) { code = 400 msg = 'Invalid volume' } else { sendTaskbarButtonClick('volume', volume / 100) } break } case '/mute': { const mute = querystring.parse(query ?? '').mute if (mute == 'true') { sendTaskbarButtonClick('mute', true) } else if (mute == 'false') { sendTaskbarButtonClick('mute', false) } else { code = 400 msg = 'Invalid mute value' } break } case '/subscribe-player-status': try { handleSubscribePlayerStatus(req, res, query) return } catch (err) { console.log(err) code = 500 msg = 'Error' } break default: code = 401 msg = 'Forbidden' break } sendResponse(res, code, msg) }) httpServer.on('error', error => { console.log(error) reject(error) }) httpServer.on('connection', (socket) => { sockets.add(socket) socket.once('close', () => { sockets.delete(socket) }) socket.setTimeout(4000) }) httpServer.on('listening', () => { const addr = httpServer.address() // console.log(addr) if (!addr) { reject(new Error('address is null')) return } resolve() }) httpServer.listen(port, ip) }) const handleStopServer = async() => new Promise((resolve, reject) => { if (!httpServer) return httpServer.close((err) => { if (err) { reject(err) return } resolve() }) for (const socket of sockets) socket.destroy() sockets.clear() responses.clear() }) const sendStatus = (status: Partial) => { if (!responses.size) return for (const [resp, keys] of responses) { for (const [k, v] of Object.entries(status)) { if (!keys.includes(k as SubscribeKeys)) continue resp.write(`event: ${k}\n`) resp.write(`data: ${JSON.stringify(v)}\n\n`) } } } export const stopServer = async() => { global.lx.event_app.off('player_status', sendStatus) if (!status.status) { status.status = false status.message = '' status.address = '' return status } await handleStopServer().then(() => { status.status = false status.message = '' status.address = '' }).catch(err => { console.log(err) status.message = err.message }) return status } export const startServer = async(port: number, bindLan: boolean) => { if (status.status) await stopServer() await handleStartServer(port, bindLan ? '0.0.0.0' : '127.0.0.1').then(() => { status.status = true status.message = '' let address = ['127.0.0.1'] if (bindLan) address = [...address, ...getAddress()] status.address = address.join(', ') }).catch(err => { console.log(err) status.status = false status.message = err.message status.address = '' }) global.lx.event_app.on('player_status', sendStatus) return status } export const getStatus = (): LX.OpenAPI.Status => status ================================================ FILE: src/main/modules/sync/client/auth.ts ================================================ import { request, generateRsaKey } from './utils' import { getSyncAuthKey, setSyncAuthKey } from './data' import log from '../log' import { aesDecrypt, aesEncrypt, getComputerName, rsaDecrypt } from '../utils' import { toMD5 } from '@common/utils/nodejs' import { SYNC_CODE } from '@common/constants_sync' const hello = async(urlInfo: LX.Sync.Client.UrlInfo) => request(`${urlInfo.httpProtocol}//${urlInfo.hostPath}/hello`) .then(({ text }) => text == SYNC_CODE.helloMsg) .catch((err: any) => { log.error('[auth] hello', err.message) console.log(err) return false }) const getServerId = async(urlInfo: LX.Sync.Client.UrlInfo) => request(`${urlInfo.httpProtocol}//${urlInfo.hostPath}/id`) .then(({ text }) => { if (!text.startsWith(SYNC_CODE.idPrefix)) return '' return text.replace(SYNC_CODE.idPrefix, '') }) .catch((err: any) => { log.error('[auth] getServerId', err.message) console.log(err) throw err }) const codeAuth = async(urlInfo: LX.Sync.Client.UrlInfo, serverId: string, authCode: string) => { let key = toMD5(authCode).substring(0, 16) // const iv = Buffer.from(key.split('').reverse().join('')).toString('base64') key = Buffer.from(key).toString('base64') let { publicKey, privateKey } = await generateRsaKey() publicKey = publicKey.replace(/\n/g, '') .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', '') const msg = aesEncrypt(`${SYNC_CODE.authMsg}\n${publicKey}\n${getComputerName()}\nlx_music_desktop`, key) // console.log(msg, key) return request(`${urlInfo.httpProtocol}//${urlInfo.hostPath}/ah`, { headers: { m: msg } }).then(async({ text, code }) => { // console.log(text) switch (text) { case SYNC_CODE.msgBlockedIp: throw new Error(SYNC_CODE.msgBlockedIp) case SYNC_CODE.authFailed: throw new Error(SYNC_CODE.authFailed) default: if (code != 200) throw new Error(SYNC_CODE.authFailed) } let msg try { msg = rsaDecrypt(Buffer.from(text, 'base64'), privateKey).toString() } catch (err: any) { log.error('[auth] codeAuth decryptMsg error', err.message) throw new Error(SYNC_CODE.authFailed) } // console.log(msg) if (!msg) return Promise.reject(new Error(SYNC_CODE.authFailed)) const info = JSON.parse(msg) as LX.Sync.ClientKeyInfo void setSyncAuthKey(serverId, info) return info }) } const keyAuth = async(urlInfo: LX.Sync.Client.UrlInfo, keyInfo: LX.Sync.ClientKeyInfo) => { const msg = aesEncrypt(SYNC_CODE.authMsg + getComputerName(), keyInfo.key) // eslint-disable-next-line @typescript-eslint/promise-function-async return request(`${urlInfo.httpProtocol}//${urlInfo.hostPath}/ah`, { headers: { i: keyInfo.clientId, m: msg } }).then(({ text, code }) => { if (code != 200) throw new Error(SYNC_CODE.authFailed) let msg try { msg = aesDecrypt(text, keyInfo.key) } catch (err: any) { log.error('[auth] keyAuth decryptMsg error', err.message) throw new Error(SYNC_CODE.authFailed) } if (msg != SYNC_CODE.helloMsg) return Promise.reject(new Error(SYNC_CODE.authFailed)) }) } const auth = async(urlInfo: LX.Sync.Client.UrlInfo, serverId: string, authCode?: string) => { if (authCode) return codeAuth(urlInfo, serverId, authCode) const keyInfo = await getSyncAuthKey(serverId) if (!keyInfo) throw new Error(SYNC_CODE.missingAuthCode) await keyAuth(urlInfo, keyInfo) return keyInfo } export default async(urlInfo: LX.Sync.Client.UrlInfo, authCode?: string) => { console.log('connect: ', urlInfo.href, authCode) if (!await hello(urlInfo)) throw new Error(SYNC_CODE.connectServiceFailed) const serverId = await getServerId(urlInfo) if (!serverId) throw new Error(SYNC_CODE.getServiceIdFailed) return auth(urlInfo, serverId, authCode) } ================================================ FILE: src/main/modules/sync/client/client.ts ================================================ import WebSocket from 'ws' import { encryptMsg, decryptMsg } from './utils' import { callObj } from './sync' // import { action as commonAction } from '@root/store/modules/common' // import { getStore } from '@root/store' // import registerSyncListHandler from './syncList' import log from '../log' import { dateFormat } from '@common/utils/common' import { aesEncrypt } from '../utils' import { sendClientStatus } from '@main/modules/winMain' import { createMsg2call } from 'message2call' import { SYNC_CLOSE_CODE, SYNC_CODE } from '@common/constants_sync' import { getAddress } from '@common/utils/nodejs' let status: LX.Sync.ClientStatus = { status: false, message: '', address: [], } export const sendSyncStatus = (newStatus: Omit) => { status.status = newStatus.status status.message = newStatus.message if (status.status) { status.address = getAddress() } sendClientStatus(status) } export const sendSyncMessage = (message: string) => { status.message = message sendClientStatus(status) } const heartbeatTools = { failedNum: 0, maxTryNum: 100000, stepMs: 3000, connectTimeout: null as NodeJS.Timeout | null, pingTimeout: null as NodeJS.Timeout | null, delayRetryTimeout: null as NodeJS.Timeout | null, handleOpen() { console.log('open') // this.failedNum = 0 this.heartbeat() }, heartbeat() { if (this.pingTimeout) clearTimeout(this.pingTimeout) // Use `WebSocket#terminate()`, which immediately destroys the connection, // instead of `WebSocket#close()`, which waits for the close timer. // Delay should be equal to the interval at which your server // sends out pings plus a conservative assumption of the latency. this.pingTimeout = setTimeout(() => { client?.terminate() }, 30000 + 1000) }, reConnnect() { this.clearTimeout() // client = null if (!client) return if (++this.failedNum > this.maxTryNum) { this.failedNum = 0 sendSyncStatus({ status: false, message: 'Connect error', }) throw new Error('connect error') } const waitTime = Math.min(2000 + Math.floor(this.failedNum / 2) * this.stepMs, 15000) // sendSyncStatus({ // status: false, // message: `Waiting ${waitTime / 1000}s reconnnect...`, // }) this.delayRetryTimeout = setTimeout(() => { this.delayRetryTimeout = null if (!client) return console.log(dateFormat(new Date()), 'reconnnect...') sendSyncStatus({ status: false, message: `Try reconnnect... (${this.failedNum})`, }) connect(client.data.urlInfo, client.data.keyInfo) }, waitTime) }, clearTimeout() { if (this.connectTimeout) { clearTimeout(this.connectTimeout) this.connectTimeout = null } if (this.delayRetryTimeout) { clearTimeout(this.delayRetryTimeout) this.delayRetryTimeout = null } if (this.pingTimeout) { clearTimeout(this.pingTimeout) this.pingTimeout = null } }, connect(socket: LX.Sync.Client.Socket) { console.log('heartbeatTools connect') this.connectTimeout = setTimeout(() => { this.connectTimeout = null if (client) { try { client.close(SYNC_CLOSE_CODE.failed) } catch {} } if (++this.failedNum > this.maxTryNum) { this.failedNum = 0 sendSyncStatus({ status: false, message: 'Connect error', }) throw new Error('connect error') } sendSyncStatus({ status: false, message: 'Connect timeout, try reconnect...', }) this.reConnnect() }, 2 * 60 * 1000) socket.on('open', () => { if (this.connectTimeout) { clearTimeout(this.connectTimeout) this.connectTimeout = null } this.handleOpen() }) socket.on('ping', () => { this.heartbeat() }) socket.on('close', (code) => { console.log(code) switch (code) { case SYNC_CLOSE_CODE.normal: case SYNC_CLOSE_CODE.failed: return } this.reConnnect() }) }, } let client: LX.Sync.Client.Socket | null // let listSyncPromise: Promise export const connect = (urlInfo: LX.Sync.Client.UrlInfo, keyInfo: LX.Sync.ClientKeyInfo) => { client = new WebSocket(`${urlInfo.wsProtocol}//${urlInfo.hostPath}/socket?i=${encodeURIComponent(keyInfo.clientId)}&t=${encodeURIComponent(aesEncrypt(SYNC_CODE.msgConnect, keyInfo.key))}`, { }) as LX.Sync.Client.Socket client.data = { keyInfo, urlInfo, } heartbeatTools.connect(client) let closeEvents: Array<(err: Error) => (void | Promise)> = [] let disconnected = true const message2read = createMsg2call({ funcsObj: { ...callObj, finished() { log.info('sync list success') client!.isReady = true sendSyncStatus({ status: true, message: '', }) heartbeatTools.failedNum = 0 }, }, timeout: 120 * 1000, sendMessage(data) { if (disconnected) throw new Error('disconnected') void encryptMsg(keyInfo, JSON.stringify(data)).then((data) => { client?.send(data) }).catch((err) => { log.error('encrypt msg error: ', err) client?.close(SYNC_CLOSE_CODE.failed) }) }, onCallBeforeParams(rawArgs) { return [client, ...rawArgs] }, onError(error, path, groupName) { const name = groupName ?? '' log.error(`sync call ${name} ${path.join('.')} error:`, error) // if (groupName == null) return // client?.close(SYNC_CLOSE_CODE.failed) // sendSyncStatus({ // status: false, // message: error.message, // }) }, }) client.remote = message2read.remote client.remoteQueueList = message2read.createQueueRemote('list') client.remoteQueueDislike = message2read.createQueueRemote('dislike') client.addEventListener('message', ({ data }) => { if (data == 'ping') return if (typeof data === 'string') { void decryptMsg(keyInfo, data).then((data) => { let syncData: LX.Sync.ServerSyncActions try { syncData = JSON.parse(data) } catch (err) { log.error('parse msg error: ', err) client?.close(SYNC_CLOSE_CODE.failed) return } message2read.message(syncData) }).catch((error) => { log.error('decrypt msg error: ', error) client?.close(SYNC_CLOSE_CODE.failed) }) } }) client.onClose = function(handler: typeof closeEvents[number]) { closeEvents.push(handler) return () => { closeEvents.splice(closeEvents.indexOf(handler), 1) } } const initMessage = 'Wait syncing...' client.addEventListener('open', () => { log.info('connect') // const store = getStore() // global.lx.syncKeyInfo = keyInfo client!.isReady = false client!.moduleReadys = { list: false, dislike: false, } disconnected = false sendSyncStatus({ status: false, message: initMessage, }) }) client.addEventListener('close', ({ code }) => { const err = new Error('closed') try { for (const handler of closeEvents) void handler(err) } catch (err: any) { log.error(err?.message) } closeEvents = [] disconnected = true message2read.destroy() switch (code) { case SYNC_CLOSE_CODE.normal: // case SYNC_CLOSE_CODE.failed: sendSyncStatus({ status: false, message: '', }) break case SYNC_CLOSE_CODE.failed: if (!status.message || status.message == initMessage) { sendSyncStatus({ status: false, message: 'failed', }) } break } }) client.addEventListener('error', ({ message }) => { sendSyncStatus({ status: false, message, }) }) } export const disconnect = async() => { if (!client) return log.info('disconnecting...') client.close(SYNC_CLOSE_CODE.normal) client = null heartbeatTools.clearTimeout() heartbeatTools.failedNum = 0 } export const getStatus = (): LX.Sync.ClientStatus => status ================================================ FILE: src/main/modules/sync/client/data.ts ================================================ import fs from 'node:fs' import path from 'node:path' import { File } from '../../../../common/constants_sync' import { exists } from '../utils' let syncAuthKeys: Record const saveSyncAuthKeys = async() => { const syncAuthKeysFilePath = path.join(global.lxDataPath, File.clientDataPath, File.syncAuthKeysJSON) return fs.promises.writeFile(syncAuthKeysFilePath, JSON.stringify(syncAuthKeys), 'utf8') } export const initClientInfo = async() => { if (syncAuthKeys != null) return const syncAuthKeysFilePath = path.join(global.lxDataPath, File.clientDataPath, File.syncAuthKeysJSON) if (await fs.promises.stat(syncAuthKeysFilePath).then(() => true).catch(() => false)) { // eslint-disable-next-line require-atomic-updates syncAuthKeys = JSON.parse((await fs.promises.readFile(syncAuthKeysFilePath)).toString()) } else { // eslint-disable-next-line require-atomic-updates syncAuthKeys = {} const syncDataPath = path.join(global.lxDataPath, File.clientDataPath) if (!await exists(syncDataPath)) { await fs.promises.mkdir(syncDataPath, { recursive: true }) } void saveSyncAuthKeys() } } export const getSyncAuthKey = async(serverId: string) => { await initClientInfo() return syncAuthKeys[serverId] ?? null } export const setSyncAuthKey = async(serverId: string, info: LX.Sync.ClientKeyInfo) => { await initClientInfo() syncAuthKeys[serverId] = info void saveSyncAuthKeys() } // let syncHost: string // export const getSyncHost = async() => { // if (syncHost === undefined) { // const store = getStore(STORE_NAMES.SYNC) // syncHost = (store.get('syncHost') as typeof syncHost | null) ?? '' // } // return syncHost // } // export const setSyncHost = async(host: string) => { // // let hostInfo = await getData(syncHostPrefix) || {} // // hostInfo.host = host // // hostInfo.port = port // syncHost = host // const store = getStore(STORE_NAMES.SYNC) // store.set('syncHost', syncHost) // } // let syncHostHistory: string[] // export const getSyncHostHistory = async() => { // if (syncHostHistory === undefined) { // const store = getStore(STORE_NAMES.SYNC) // syncHostHistory = (store.get('syncHostHistory') as string[]) ?? [] // } // return syncHostHistory // } // export const addSyncHostHistory = async(host: string) => { // let syncHostHistory = await getSyncHostHistory() // if (syncHostHistory.some(h => h == host)) return // syncHostHistory.unshift(host) // if (syncHostHistory.length > 20) syncHostHistory = syncHostHistory.slice(0, 20) // 最多存储20个 // const store = getStore(STORE_NAMES.SYNC) // store.set('syncHostHistory', syncHostHistory) // } // export const removeSyncHostHistory = async(index: number) => { // syncHostHistory.splice(index, 1) // const store = getStore(STORE_NAMES.SYNC) // store.set('syncHostHistory', syncHostHistory) // } ================================================ FILE: src/main/modules/sync/client/index.ts ================================================ import handleAuth from './auth' import { connect as socketConnect, disconnect as socketDisconnect, sendSyncStatus, sendSyncMessage } from './client' // import { getSyncHost } from '@root/utils/data' import log from '../log' import { parseUrl } from './utils' import migrateData from '../migrate' import { SYNC_CODE } from '@common/constants_sync' let connectId = 0 const handleConnect = async(host: string, authCode?: string) => { // const hostInfo = await getSyncHost() // console.log(hostInfo) // if (!hostInfo || !hostInfo.host || !hostInfo.port) throw new Error(SYNC_CODE.unknownServiceAddress) const id = connectId const urlInfo = parseUrl(host) await disconnectServer(false) if (id != connectId) return const keyInfo = await handleAuth(urlInfo, authCode) if (id != connectId) return socketConnect(urlInfo, keyInfo) } const handleDisconnect = async() => { await socketDisconnect() } const connectServer = async(host: string, authCode?: string) => { sendSyncStatus({ status: false, message: SYNC_CODE.connecting, }) const id = connectId await migrateData(global.lxDataPath) return handleConnect(host, authCode).catch(async err => { if (id != connectId) return sendSyncStatus({ status: false, message: err.message, }) switch (err.message) { case SYNC_CODE.connectServiceFailed: case SYNC_CODE.missingAuthCode: break default: log.r_warn(err.message) break } return Promise.reject(err) }) } const disconnectServer = async(isResetStatus = true) => handleDisconnect().then(() => { log.info('disconnect...') if (isResetStatus) { connectId++ sendSyncStatus({ status: false, message: '', }) } }).catch((err: any) => { log.error(`disconnect error: ${err.message as string}`) sendSyncMessage(err.message) }) export { connectServer, disconnectServer, } export { getStatus, } from './client' ================================================ FILE: src/main/modules/sync/client/modules/dislike/handler.ts ================================================ // 这个文件导出的方法将暴露给服务端调用,第一个参数固定为当前 socket 对象 import { handleRemoteDislikeAction, getLocalDislikeData, setLocalDislikeData } from '@main/modules/sync/dislikeEvent' import { toMD5 } from '@common/utils/nodejs' import { removeSelectModeListener, sendCloseSelectMode, sendSelectMode } from '@main/modules/winMain' import log from '@main/modules/sync/log' import { registerEvent, unregisterEvent } from './localEvent' const logInfo = (eventName: string, success = false) => { log.info(`[${eventName}]${eventName.replace('dislike:sync:dislike_sync_', '').replaceAll('_', ' ')}${success ? ' success' : ''}`) } // const logError = (eventName: string, err: Error) => { // log.error(`[${eventName}]${eventName.replace('dislike:sync:dislike_sync_', '').replaceAll('_', ' ')} error: ${err.message}`) // } const getSyncMode = async(socket: LX.Sync.Client.Socket): Promise => new Promise((resolve, reject) => { const handleDisconnect = (err: Error) => { sendCloseSelectMode() removeSelectModeListener() reject(err) } let removeEventClose = socket.onClose(handleDisconnect) sendSelectMode(socket.data.keyInfo.serverName, 'dislike', (mode) => { if (mode == null) { reject(new Error('cancel')) return } resolve(mode) removeSelectModeListener() removeEventClose() }) }) const handler: LX.Sync.ClientSyncHandlerDislikeActions = { async onDislikeSyncAction(socket, action) { if (!socket.moduleReadys?.dislike) return await handleRemoteDislikeAction(action) }, async dislike_sync_get_md5(socket) { logInfo('dislike:sync:dislike_sync_get_md5') return toMD5((await getLocalDislikeData()).trim()) }, async dislike_sync_get_sync_mode(socket) { return getSyncMode(socket) }, async dislike_sync_get_list_data(socket) { logInfo('dislike:sync:dislike_sync_get_list_data') return getLocalDislikeData() }, async dislike_sync_set_list_data(socket, data) { logInfo('dislike:sync:dislike_sync_set_list_data') await setLocalDislikeData(data) }, async dislike_sync_finished(socket) { logInfo('dislike:sync:finished') socket.moduleReadys.dislike = true registerEvent(socket) socket.onClose(() => { unregisterEvent() }) }, } export default handler ================================================ FILE: src/main/modules/sync/client/modules/dislike/index.ts ================================================ export { default as handler } from './handler' export * from './localEvent' ================================================ FILE: src/main/modules/sync/client/modules/dislike/localEvent.ts ================================================ import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { registerDislikeActionEvent } from '@main/modules/sync/dislikeEvent' let unregisterLocalListAction: (() => void) | null export const registerEvent = (socket: LX.Sync.Client.Socket) => { // socket = _socket // socket.onClose(() => { // unregisterLocalListAction?.() // unregisterLocalListAction = null // }) unregisterEvent() unregisterLocalListAction = registerDislikeActionEvent((action) => { if (!socket.moduleReadys?.dislike) return void socket.remoteQueueDislike.onDislikeSyncAction(action).catch(err => { // TODO send status socket.moduleReadys.dislike = false socket.close(SYNC_CLOSE_CODE.failed) console.log(err.message) }) }) } export const unregisterEvent = () => { unregisterLocalListAction?.() unregisterLocalListAction = null } ================================================ FILE: src/main/modules/sync/client/modules/index.ts ================================================ import * as list from './list' import * as dislike from './dislike' // export * as theme from './theme' export const callObj = Object.assign({}, list.handler, dislike.handler, ) export const modules = { list, dislike, } export const featureVersion = { list: 1, dislike: 1, } as const ================================================ FILE: src/main/modules/sync/client/modules/list/handler.ts ================================================ // 这个文件导出的方法将暴露给服务端调用,第一个参数固定为当前 socket 对象 import { handleRemoteListAction, getLocalListData, setLocalListData, } from '@main/modules/sync/listEvent' import { toMD5 } from '@common/utils/nodejs' import { removeSelectModeListener, sendCloseSelectMode, sendSelectMode } from '@main/modules/winMain' import log from '@main/modules/sync/log' import { registerEvent, unregisterEvent } from './localEvent' const logInfo = (eventName: string, success = false) => { log.info(`[${eventName}]${eventName.replace('list:sync:list_sync_', '').replaceAll('_', ' ')}${success ? ' success' : ''}`) } // const logError = (eventName: string, err: Error) => { // log.error(`[${eventName}]${eventName.replace('list:sync:list_sync_', '').replaceAll('_', ' ')} error: ${err.message}`) // } const getSyncMode = async(socket: LX.Sync.Client.Socket): Promise => new Promise((resolve, reject) => { const handleDisconnect = (err: Error) => { sendCloseSelectMode() removeSelectModeListener() reject(err) } let removeEventClose = socket.onClose(handleDisconnect) sendSelectMode(socket.data.keyInfo.serverName, 'list', (mode) => { if (mode == null) { reject(new Error('cancel')) return } resolve(mode) removeSelectModeListener() removeEventClose() }) }) const handler: LX.Sync.ClientSyncHandlerListActions = { async onListSyncAction(socket, action) { if (!socket.moduleReadys?.list) return await handleRemoteListAction(action) }, async list_sync_get_md5(socket) { logInfo('list:sync:list_sync_get_md5') return toMD5(JSON.stringify(await getLocalListData())) }, async list_sync_get_sync_mode(socket) { return getSyncMode(socket) }, async list_sync_get_list_data(socket) { logInfo('list:sync:list_sync_get_list_data') return getLocalListData() }, async list_sync_set_list_data(socket, data) { logInfo('list:sync:list_sync_set_list_data') await setLocalListData(data) }, async list_sync_finished(socket) { logInfo('list:sync:finished') socket.moduleReadys.list = true registerEvent(socket) socket.onClose(() => { unregisterEvent() }) }, } export default handler ================================================ FILE: src/main/modules/sync/client/modules/list/index.ts ================================================ export { default as handler } from './handler' export * from './localEvent' ================================================ FILE: src/main/modules/sync/client/modules/list/localEvent.ts ================================================ import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { registerListActionEvent } from '@main/modules/sync/listEvent' let unregisterLocalListAction: (() => void) | null export const registerEvent = (socket: LX.Sync.Client.Socket) => { // socket = _socket // socket.onClose(() => { // unregisterLocalListAction?.() // unregisterLocalListAction = null // }) unregisterEvent() unregisterLocalListAction = registerListActionEvent((action) => { if (!socket.moduleReadys?.list) return void socket.remoteQueueList.onListSyncAction(action).catch(err => { // TODO send status socket.moduleReadys.list = false socket.close(SYNC_CLOSE_CODE.failed) console.log(err.message) }) }) } export const unregisterEvent = () => { unregisterLocalListAction?.() unregisterLocalListAction = null } ================================================ FILE: src/main/modules/sync/client/sync/handler.ts ================================================ // 这个文件导出的方法将暴露给服务端调用,第一个参数固定为当前 socket 对象 // import { getUserSpace } from '@/user' // import { modules } from '../modules' import { featureVersion } from '../modules' const handler: Omit, 'finished'> = { async getEnabledFeatures(socket, serverType, supportedFeatures) { // const userSpace = getUserSpace(socket.userInfo.name) const features: LX.Sync.EnabledFeatures = {} switch (serverType) { case 'server': if (featureVersion.list == supportedFeatures.list) { features.list = { skipSnapshot: false } } if (featureVersion.dislike == supportedFeatures.dislike) { features.dislike = { skipSnapshot: false } } return features case 'desktop-app': default: if (featureVersion.list == supportedFeatures.list) { features.list = { skipSnapshot: false } } if (featureVersion.dislike == supportedFeatures.dislike) { features.dislike = { skipSnapshot: false } } return features } }, } export default handler ================================================ FILE: src/main/modules/sync/client/sync/index.ts ================================================ import handler from './handler' import { callObj as _callObj } from '../modules' export { modules } from '../modules' export const callObj = { ...handler, ..._callObj, } ================================================ FILE: src/main/modules/sync/client/utils.ts ================================================ import { generateKeyPair } from 'node:crypto' import { httpFetch, type RequestOptions } from '@main/utils/request' import { decodeData, encodeData } from '../utils' export const request = async(url: string, options: RequestOptions = { }) => { return httpFetch(url, { ...options, timeout: options.timeout ?? 10000, }).then(response => { return { text: response.body, code: response.statusCode, } }) // const controller = new AbortController() // let id: number | null = setTimeout(() => { // id = null // controller.abort() // }, timeout) // return fetch(url, { // ...options, // signal: controller.signal, // // eslint-disable-next-line @typescript-eslint/promise-function-async // }).then(async(response) => { // const text = await response.text() // return { // text, // code: response.status, // } // }).catch(err => { // // console.log(err, err.code, err.message) // throw err // }).finally(() => { // if (id == null) return // clearTimeout(id) // }) } // export const aesEncrypt = (text: string, key: string, iv: string) => { // const cipher = createCipheriv('aes-128-cbc', Buffer.from(key, 'base64'), Buffer.from(iv, 'base64')) // return Buffer.concat([cipher.update(Buffer.from(text)), cipher.final()]).toString('base64') // } // export const aesDecrypt = (text: string, key: string, iv: string) => { // const decipher = createDecipheriv('aes-128-cbc', Buffer.from(key, 'base64'), Buffer.from(iv, 'base64')) // return Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString() // } export const generateRsaKey = async() => new Promise<{ publicKey: string, privateKey: string }>((resolve, reject) => { generateKeyPair( 'rsa', { modulusLength: 2048, // It holds a number. It is the key size in bits and is applicable for RSA, and DSA algorithm only. publicKeyEncoding: { type: 'spki', // Note the type is pkcs1 not spki format: 'pem', }, privateKeyEncoding: { type: 'pkcs8', // Note again the type is set to pkcs1 format: 'pem', // cipher: "aes-256-cbc", //Optional // passphrase: "", //Optional }, }, (err, publicKey, privateKey) => { if (err) { reject(err) return } resolve({ publicKey, privateKey, }) }, ) }) export const encryptMsg = async(keyInfo: LX.Sync.ClientKeyInfo, msg: string): Promise => { return encodeData(msg) // if (!keyInfo) return '' // return aesEncrypt(msg, keyInfo.key, keyInfo.iv) } export const decryptMsg = async(keyInfo: LX.Sync.ClientKeyInfo, enMsg: string): Promise => { return decodeData(enMsg) // if (!keyInfo) return '' // let msg = '' // try { // msg = aesDecrypt(enMsg, keyInfo.key, keyInfo.iv) // } catch (err) { // console.log(err) // } // return msg } export const parseUrl = (host: string): LX.Sync.Client.UrlInfo => { const url = new URL(host) let hostPath = url.host + url.pathname let href = url.href if (hostPath.endsWith('/')) hostPath = hostPath.replace(/\/$/, '') if (href.endsWith('/')) href = href.replace(/\/$/, '') return { wsProtocol: url.protocol == 'https:' ? 'wss:' : 'ws:', httpProtocol: url.protocol, hostPath, href, } } export const sendStatus = (status: LX.Sync.ClientStatus) => { // syncLog.log(JSON.stringify(status)) } ================================================ FILE: src/main/modules/sync/dislikeEvent.ts ================================================ export const getLocalDislikeData = async(): Promise => { return (await global.lx.worker.dbService.getDislikeListInfo()).rules } export const setLocalDislikeData = async(listData: LX.Dislike.DislikeRules) => { await global.lx.event_dislike.dislike_data_overwrite(listData, true) } export const registerDislikeActionEvent = (sendDislikeAction: (action: LX.Sync.Dislike.ActionList) => (void | Promise)) => { const dislike_music_add = async(listData: LX.Dislike.DislikeMusicInfo[], isRemote: boolean = false) => { if (isRemote) return await sendDislikeAction({ action: 'dislike_music_add', data: listData }) } const dislike_data_overwrite = async(listInfos: LX.Dislike.DislikeRules, isRemote: boolean = false) => { if (isRemote) return await sendDislikeAction({ action: 'dislike_data_overwrite', data: listInfos }) } const dislike_music_clear = async(isRemote: boolean = false) => { if (isRemote) return await sendDislikeAction({ action: 'dislike_music_clear' }) } global.lx.event_dislike.on('dislike_music_add', dislike_music_add) global.lx.event_dislike.on('dislike_data_overwrite', dislike_data_overwrite) global.lx.event_dislike.on('dislike_music_clear', dislike_music_clear) return () => { global.lx.event_dislike.off('dislike_music_add', dislike_music_add) global.lx.event_dislike.off('dislike_data_overwrite', dislike_data_overwrite) global.lx.event_dislike.off('dislike_music_clear', dislike_music_clear) } } export const handleRemoteDislikeAction = async(event: LX.Sync.Dislike.ActionList) => { // console.log('handleRemoteDislikeAction', event) switch (event.action) { case 'dislike_music_add': await global.lx.event_dislike.dislike_music_add(event.data, true) break case 'dislike_data_overwrite': await global.lx.event_dislike.dislike_data_overwrite(event.data, true) break case 'dislike_music_clear': await global.lx.event_dislike.dislike_music_clear(true) break default: throw new Error('unknown list sync action') } } ================================================ FILE: src/main/modules/sync/index.ts ================================================ // import Event from './event/event' import { disconnectServer } from './client' import { stopServer } from './server' // import eventNames from './event/name' export { startServer, stopServer, getStatus as getServerStatus, generateCode, getDevices as getServerDevices, removeDevice as removeServerDevice, } from './server' export { connectServer, disconnectServer, getStatus as getClientStatus, } from './client' export default () => { global.lx.event_app.on('main_window_close', () => { if (global.lx.appSetting['sync.mode'] == 'server') { void stopServer() } else { void disconnectServer() } }) } ================================================ FILE: src/main/modules/sync/listEvent.ts ================================================ import { LIST_IDS } from '@common/constants' // 构建列表信息对象,用于统一字段位置顺序 export const buildUserListInfoFull = ({ id, name, source, sourceListId, list, locationUpdateTime }: LX.List.UserListInfoFull) => { return { id, name, source, sourceListId, locationUpdateTime, list, } } export const getLocalListData = async(): Promise => { const lists: LX.Sync.List.ListData = { defaultList: await global.lx.worker.dbService.getListMusics(LIST_IDS.DEFAULT), loveList: await global.lx.worker.dbService.getListMusics(LIST_IDS.LOVE), userList: [], } const userListInfos = await global.lx.worker.dbService.getAllUserList() for await (const list of userListInfos) { lists.userList.push(await global.lx.worker.dbService.getListMusics(list.id) .then(musics => buildUserListInfoFull({ ...list, list: musics }))) } return lists } export const setLocalListData = async(listData: LX.Sync.List.ListData) => { await global.lx.event_list.list_data_overwrite(listData, true) } export const registerListActionEvent = (sendListAction: (action: LX.Sync.List.ActionList) => (void | Promise)) => { const list_data_overwrite = async(listData: MakeOptional, isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_data_overwrite', data: listData }) } const list_create = async(position: number, listInfos: LX.List.UserListInfo[], isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_create', data: { position, listInfos } }) } const list_remove = async(ids: string[], isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_remove', data: ids }) } const list_update = async(lists: LX.List.UserListInfo[], isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_update', data: lists }) } const list_update_position = async(position: number, ids: string[], isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_update_position', data: { position, ids } }) } const list_music_overwrite = async(listId: string, musicInfos: LX.Music.MusicInfo[], isRemote: boolean = false) => { if (isRemote || listId == LIST_IDS.TEMP) return await sendListAction({ action: 'list_music_overwrite', data: { listId, musicInfos } }) } const list_music_add = async(id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_music_add', data: { id, musicInfos, addMusicLocationType } }) } const list_music_move = async(fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_music_move', data: { fromId, toId, musicInfos, addMusicLocationType } }) } const list_music_remove = async(listId: string, ids: string[], isRemote: boolean = false) => { if (isRemote || listId == LIST_IDS.TEMP) return await sendListAction({ action: 'list_music_remove', data: { listId, ids } }) } const list_music_update = async(musicInfos: LX.List.ListActionMusicUpdate, isRemote: boolean = false) => { musicInfos = musicInfos.filter(item => item.id != LIST_IDS.TEMP) if (isRemote || !musicInfos.length) return await sendListAction({ action: 'list_music_update', data: musicInfos }) } const list_music_clear = async(ids: string[], isRemote: boolean = false) => { if (isRemote) return await sendListAction({ action: 'list_music_clear', data: ids }) } const list_music_update_position = async(listId: string, position: number, ids: string[], isRemote: boolean = false) => { if (isRemote || listId == LIST_IDS.TEMP) return await sendListAction({ action: 'list_music_update_position', data: { listId, position, ids } }) } global.lx.event_list.on('list_data_overwrite', list_data_overwrite) global.lx.event_list.on('list_create', list_create) global.lx.event_list.on('list_remove', list_remove) global.lx.event_list.on('list_update', list_update) global.lx.event_list.on('list_update_position', list_update_position) global.lx.event_list.on('list_music_overwrite', list_music_overwrite) global.lx.event_list.on('list_music_add', list_music_add) global.lx.event_list.on('list_music_move', list_music_move) global.lx.event_list.on('list_music_remove', list_music_remove) global.lx.event_list.on('list_music_update', list_music_update) global.lx.event_list.on('list_music_clear', list_music_clear) global.lx.event_list.on('list_music_update_position', list_music_update_position) return () => { global.lx.event_list.off('list_data_overwrite', list_data_overwrite) global.lx.event_list.off('list_create', list_create) global.lx.event_list.off('list_remove', list_remove) global.lx.event_list.off('list_update', list_update) global.lx.event_list.off('list_update_position', list_update_position) global.lx.event_list.off('list_music_overwrite', list_music_overwrite) global.lx.event_list.off('list_music_add', list_music_add) global.lx.event_list.off('list_music_move', list_music_move) global.lx.event_list.off('list_music_remove', list_music_remove) global.lx.event_list.off('list_music_update', list_music_update) global.lx.event_list.off('list_music_clear', list_music_clear) global.lx.event_list.off('list_music_update_position', list_music_update_position) } } export const handleRemoteListAction = async({ action, data }: LX.Sync.List.ActionList) => { // console.log('handleRemoteListAction', action) switch (action) { case 'list_data_overwrite': await global.lx.event_list.list_data_overwrite(data, true) break case 'list_create': await global.lx.event_list.list_create(data.position, data.listInfos, true) break case 'list_remove': await global.lx.event_list.list_remove(data, true) break case 'list_update': await global.lx.event_list.list_update(data, true) break case 'list_update_position': await global.lx.event_list.list_update_position(data.position, data.ids, true) break case 'list_music_add': await global.lx.event_list.list_music_add(data.id, data.musicInfos, data.addMusicLocationType, true) break case 'list_music_move': await global.lx.event_list.list_music_move(data.fromId, data.toId, data.musicInfos, data.addMusicLocationType, true) break case 'list_music_remove': await global.lx.event_list.list_music_remove(data.listId, data.ids, true) break case 'list_music_update': await global.lx.event_list.list_music_update(data, true) break case 'list_music_update_position': await global.lx.event_list.list_music_update_position(data.listId, data.position, data.ids, true) break case 'list_music_overwrite': await global.lx.event_list.list_music_overwrite(data.listId, data.musicInfos, true) break case 'list_music_clear': await global.lx.event_list.list_music_clear(data, true) break default: throw new Error('unknown list sync action') } } ================================================ FILE: src/main/modules/sync/log.ts ================================================ import { log as writeLog } from '@common/utils' export default { r_info(...params: any[]) { writeLog.info(...params) }, r_warn(...params: any[]) { writeLog.warn(...params) }, r_error(...params: any[]) { writeLog.error(...params) }, info(...params: any[]) { // if (global.lx.isEnableSyncLog) writeLog.info(...params) console.log(...params) }, warn(...params: any[]) { // if (global.lx.isEnableSyncLog) writeLog.warn(...params) console.warn(...params) }, error(...params: any[]) { // if (global.lx.isEnableSyncLog) writeLog.error(...params) console.warn(...params) }, } ================================================ FILE: src/main/modules/sync/migrate.ts ================================================ import { File } from '../../../common/constants_sync' import fs from 'node:fs' import path from 'node:path' import { exists } from './utils' interface ServerKeyInfo { clientId: string key: string deviceName: string lastSyncDate?: number snapshotKey?: string lastConnectDate?: number isMobile: boolean } // 迁移 v2 sync 数据 export default async(dataPath: string) => { const syncDataPath = path.join(dataPath, 'sync') // console.log(syncDataPath) if (await exists(syncDataPath)) return const oldInfoPath = path.join(dataPath, 'sync.json') // console.log(oldInfoPath) if (!await exists(oldInfoPath)) return const serverSyncDataPath = path.join(dataPath, File.serverDataPath) const clientSyncDataPath = path.join(dataPath, File.clientDataPath) await fs.promises.mkdir(serverSyncDataPath, { recursive: true }) await fs.promises.mkdir(clientSyncDataPath, { recursive: true }) const info = JSON.parse((await fs.promises.readFile(oldInfoPath)).toString()) const serverInfoPath = path.join(serverSyncDataPath, File.serverInfoJSON) const devicesInfoPath = path.join(serverSyncDataPath, File.userDevicesJSON) const listDir = path.join(serverSyncDataPath, File.listDir) await fs.promises.mkdir(listDir) const snapshotInfo = info.snapshotInfo delete info.snapshotInfo snapshotInfo.clients = {} for (const device of Object.values(info.clients)) { snapshotInfo.clients[device.clientId] = { snapshotKey: device.snapshotKey, lastSyncDate: device.lastSyncDate, } device.lastConnectDate = device.lastSyncDate delete device.lastSyncDate delete device.snapshotKey } const devicesInfo = { userName: 'default', clients: info.clients, } await fs.promises.writeFile(serverInfoPath, JSON.stringify({ serverId: info.serverId, version: 2 })) await fs.promises.writeFile(devicesInfoPath, JSON.stringify(devicesInfo)) await fs.promises.writeFile(path.join(listDir, File.listSnapshotInfoJSON), JSON.stringify(snapshotInfo)) const snapshotPath = path.join(listDir, File.listSnapshotDir) await fs.promises.mkdir(snapshotPath) const snapshots = (await fs.promises.readdir(dataPath)).filter(name => name.startsWith('snapshot_')) if (snapshots.length) { for (const file of snapshots) { await fs.promises.copyFile(path.join(dataPath, file), path.join(snapshotPath, file)) } } await fs.promises.writeFile(path.join(clientSyncDataPath, File.syncAuthKeysJSON), JSON.stringify(info.syncAuthKey)) for (const file of snapshots) { await fs.promises.unlink(path.join(dataPath, file)) } await fs.promises.unlink(oldInfoPath) } ================================================ FILE: src/main/modules/sync/server/index.ts ================================================ export { startServer, stopServer, getStatus, generateCode, getDevices, removeDevice, } from './server' ================================================ FILE: src/main/modules/sync/server/modules/dislike/index.ts ================================================ export * as sync from './sync' export { DislikeManage } from './manage' ================================================ FILE: src/main/modules/sync/server/modules/dislike/manage.ts ================================================ import { type UserDataManage } from '../../user' import { SnapshotDataManage } from './snapshotDataManage' import { toMD5 } from '../../utils' import { getLocalDislikeData } from '@main/modules/sync/dislikeEvent' export class DislikeManage { snapshotDataManage: SnapshotDataManage constructor(userDataManage: UserDataManage) { this.snapshotDataManage = new SnapshotDataManage(userDataManage) } createSnapshot = async() => { const listData = await this.getDislikeRules() const md5 = toMD5(listData.trim()) const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() console.log(md5, snapshotInfo.latest) if (snapshotInfo.latest == md5) return md5 if (snapshotInfo.list.includes(md5)) { snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) } else await this.snapshotDataManage.saveSnapshot(md5, listData) if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) snapshotInfo.latest = md5 snapshotInfo.time = Date.now() this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) return md5 } getCurrentListInfoKey = async() => { // const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() // if (snapshotInfo.latest) { // return snapshotInfo.latest // } // snapshotInfo.latest = toMD5((await this.getDislikeRules()).trim()) // this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) // return snapshotInfo.latest return this.createSnapshot() } getDeviceCurrentSnapshotKey = async(clientId: string) => { return this.snapshotDataManage.getDeviceCurrentSnapshotKey(clientId) } updateDeviceSnapshotKey = async(clientId: string, key: string) => { await this.snapshotDataManage.updateDeviceSnapshotKey(clientId, key) } removeDevice = async(clientId: string) => { this.snapshotDataManage.removeSnapshotInfo(clientId) } getDislikeRules = async() => { return getLocalDislikeData() } } ================================================ FILE: src/main/modules/sync/server/modules/dislike/snapshotDataManage.ts ================================================ import { throttle } from '@common/utils/common' import fs from 'node:fs' import path from 'node:path' import syncLog from '../../../log' import { getUserConfig, type UserDataManage } from '../../user/data' import { File } from '../../../../../../common/constants_sync' import { checkAndCreateDirSync } from '../../utils' interface SnapshotInfo { latest: string | null time: number list: string[] clients: Record } export class SnapshotDataManage { userDataManage: UserDataManage dislikeDir: string snapshotDir: string snapshotInfoFilePath: string snapshotInfo: SnapshotInfo clientSnapshotKeys: string[] private readonly saveSnapshotInfoThrottle: () => void isIncluedsDevice = (key: string) => { return this.clientSnapshotKeys.includes(key) } clearOldSnapshot = async() => { if (!this.snapshotInfo) return const snapshotList = this.snapshotInfo.list.filter(key => !this.isIncluedsDevice(key)) // console.log(snapshotList.length, lx.config.maxSnapshotNum) const userMaxSnapshotNum = getUserConfig(this.userDataManage.userName).maxSnapshotNum let requiredSave = snapshotList.length > userMaxSnapshotNum while (snapshotList.length > userMaxSnapshotNum) { const name = snapshotList.pop() if (name) { await this.removeSnapshot(name) this.snapshotInfo.list.splice(this.snapshotInfo.list.indexOf(name), 1) } else break } if (requiredSave) this.saveSnapshotInfo(this.snapshotInfo) } updateDeviceSnapshotKey = async(clientId: string, key: string) => { // console.log('updateDeviceSnapshotKey', key) let client = this.snapshotInfo.clients[clientId] if (!client) client = this.snapshotInfo.clients[clientId] = { snapshotKey: '', lastSyncDate: 0 } if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) client.snapshotKey = key client.lastSyncDate = Date.now() this.clientSnapshotKeys.push(key) this.saveSnapshotInfoThrottle() } getDeviceCurrentSnapshotKey = async(clientId: string) => { // console.log('updateDeviceSnapshotKey', key) const client = this.snapshotInfo.clients[clientId] return client?.snapshotKey } getSnapshotInfo = async(): Promise => { return this.snapshotInfo } saveSnapshotInfo = (info: SnapshotInfo) => { this.snapshotInfo = info this.saveSnapshotInfoThrottle() } removeSnapshotInfo = (clientId: string) => { let client = this.snapshotInfo.clients[clientId] if (!client) return if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.snapshotInfo.clients[clientId] this.saveSnapshotInfoThrottle() } getSnapshot = async(name: string) => { const filePath = path.join(this.snapshotDir, `snapshot_${name}`) let listData: LX.Dislike.DislikeRules try { listData = (await fs.promises.readFile(filePath)).toString('utf-8') } catch (err) { syncLog.warn(err) return null } return listData } saveSnapshot = async(name: string, data: string) => { syncLog.info('saveSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { fs.writeFileSync(filePath, data) } catch (err) { syncLog.error(err) throw err } } removeSnapshot = async(name: string) => { syncLog.info('removeSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { fs.unlinkSync(filePath) } catch (err) { syncLog.error(err) } } constructor(userDataManage: UserDataManage) { this.userDataManage = userDataManage this.dislikeDir = path.join(userDataManage.userDir, File.dislikeDir) checkAndCreateDirSync(this.dislikeDir) this.snapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir) checkAndCreateDirSync(this.snapshotDir) this.snapshotInfoFilePath = path.join(this.dislikeDir, File.dislikeSnapshotInfoJSON) this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath) ? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString()) : { latest: null, time: 0, list: [], clients: {} } this.saveSnapshotInfoThrottle = throttle(() => { fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { if (err) console.error(err) void this.clearOldSnapshot() }) }) this.clientSnapshotKeys = Object.values(this.snapshotInfo.clients).map(device => device.snapshotKey).filter(k => k) } } // type UserDataManages = Map // export const createUserDataManage = (user: LX.UserConfig) => { // const manage = Object.create(userDataManage) as typeof userDataManage // manage.userDir = user.dataPath // } ================================================ FILE: src/main/modules/sync/server/modules/dislike/sync/handler.ts ================================================ // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 // import { throttle } from '@common/utils/common' // import { sendSyncActionList } from '@main/modules/winMain' // import { SYNC_CLOSE_CODE } from '@/constants' // import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { getUserSpace } from '@main/modules/sync/server/user' import { handleRemoteDislikeAction } from '@main/modules/sync/dislikeEvent' // import { encryptMsg } from '@/utils/tools' const handler: LX.Sync.ServerSyncHandlerDislikeActions = { async onDislikeSyncAction(socket, action) { if (!socket.moduleReadys.dislike) return await handleRemoteDislikeAction(action) const userSpace = getUserSpace(socket.userInfo.name) const key = await userSpace.dislikeManage.createSnapshot() userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) const currentUserName = socket.userInfo.name const currentId = socket.keyInfo.clientId socket.broadcast((client) => { if (client.keyInfo.clientId == currentId || !client.moduleReadys?.dislike || client.userInfo.name != currentUserName) return void client.remoteQueueDislike.onDislikeSyncAction(action).then(async() => { return userSpace.dislikeManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.dislike = false console.log(err.message) }) }) }, } export default handler ================================================ FILE: src/main/modules/sync/server/modules/dislike/sync/index.ts ================================================ export { default as handler } from './handler' export { sync } from './sync' export * from './localEvent' ================================================ FILE: src/main/modules/sync/server/modules/dislike/sync/localEvent.ts ================================================ import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { registerDislikeActionEvent } from '../../../../dislikeEvent' import { getUserSpace } from '../../../user' // let socket: LX.Sync.Server.Socket | null let unregisterLocalListAction: (() => void) | null const sendListAction = async(wss: LX.Sync.Server.SocketServer, action: LX.Sync.Dislike.ActionList) => { // console.log('sendListAction', action.action) const userSpace = getUserSpace() let key = '' for (const client of wss.clients) { if (!client.moduleReadys?.dislike) continue // eslint-disable-next-line require-atomic-updates if (!key) key = await userSpace.dislikeManage.createSnapshot() void client.remoteQueueDislike.onDislikeSyncAction(action).then(async() => { return userSpace.dislikeManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.dislike = false console.log(err.message) }) } } export const registerEvent = (wss: LX.Sync.Server.SocketServer) => { // socket = _socket // socket.onClose(() => { // unregisterLocalListAction?.() // unregisterLocalListAction = null // }) unregisterEvent() unregisterLocalListAction = registerDislikeActionEvent((action) => { void sendListAction(wss, action) }) } export const unregisterEvent = () => { unregisterLocalListAction?.() unregisterLocalListAction = null } ================================================ FILE: src/main/modules/sync/server/modules/dislike/sync/sync.ts ================================================ // import { SYNC_CLOSE_CODE } from '../../../../constants' import { removeSelectModeListener, sendCloseSelectMode, sendSelectMode } from '@main/modules/winMain' import { getUserSpace } from '../../../user' import { getLocalDislikeData, setLocalDislikeData } from '@main/modules/sync/dislikeEvent' import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { filterRules } from '../utils' // import { LIST_IDS } from '@common/constants' // type ListInfoType = LX.Dislike.UserListInfoFull | LX.Dislike.MyDefaultListInfoFull | LX.Dislike.MyLoveListInfoFull // let wss: LX.Sync.Server.SocketServer | null let syncingId: string | null = null const wait = async(time = 1000) => await new Promise((resolve, reject) => setTimeout(resolve, time)) const getRemoteListData = async(socket: LX.Sync.Server.Socket): Promise => { console.log('getRemoteListData') return (await socket.remoteQueueDislike.dislike_sync_get_list_data()) ?? '' } const getRemoteDataMD5 = async(socket: LX.Sync.Server.Socket): Promise => { return socket.remoteQueueDislike.dislike_sync_get_md5() } // const getLocalDislikeData async(socket: LX.Sync.Server.Socket): Promise => { // return getUserSpace(socket.userInfo.name).dislikeManage.getListData() // } const getSyncMode = async(socket: LX.Sync.Server.Socket): Promise => new Promise((resolve, reject) => { const handleDisconnect = (err: Error) => { sendCloseSelectMode() removeSelectModeListener() reject(err) } let removeEventClose = socket.onClose(handleDisconnect) sendSelectMode(socket.keyInfo.deviceName, 'dislike', (mode) => { if (mode == null) { reject(new Error('cancel')) return } resolve(mode) removeSelectModeListener() removeEventClose() }) }) // const getSyncMode = async(socket: LX.Sync.Server.Socket): Promise => { // return socket.remoteQueueDislike.list_sync_get_sync_mode() // } const finishedSync = async(socket: LX.Sync.Server.Socket) => { await socket.remoteQueueDislike.dislike_sync_finished() } const setLocalList = async(socket: LX.Sync.Server.Socket, listData: LX.Dislike.DislikeRules) => { await setLocalDislikeData(listData) const userSpace = getUserSpace(socket.userInfo.name) return userSpace.dislikeManage.createSnapshot() } const overwriteRemoteListData = async(socket: LX.Sync.Server.Socket, listData: LX.Dislike.DislikeRules, key: string, excludeIds: string[] = []) => { const action = { action: 'dislike_data_overwrite', data: listData } as const const tasks: Array> = [] const userSpace = getUserSpace(socket.userInfo.name) socket.broadcast((client) => { if (excludeIds.includes(client.keyInfo.clientId) || client.userInfo?.name != socket.userInfo.name || !client.moduleReadys?.dislike) return tasks.push(client.remoteQueueDislike.onDislikeSyncAction(action).then(async() => { return userSpace.dislikeManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.list = false console.log(err.message) })) }) if (!tasks.length) return await Promise.all(tasks) } const setRemotelList = async(socket: LX.Sync.Server.Socket, listData: LX.Dislike.DislikeRules, key: string): Promise => { await socket.remoteQueueDislike.dislike_sync_set_list_data(listData) const userSpace = getUserSpace(socket.userInfo.name) await userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) } const mergeList = (socket: LX.Sync.Server.Socket, sourceListData: LX.Dislike.DislikeRules, targetListData: LX.Dislike.DislikeRules): LX.Dislike.DislikeRules => { return Array.from(filterRules(sourceListData + '\n' + targetListData)).join('\n') } const handleMergeListData = async(socket: LX.Sync.Server.Socket): Promise<[LX.Dislike.DislikeRules, boolean, boolean]> => { const mode: LX.Sync.Dislike.SyncMode = await getSyncMode(socket) if (mode == 'cancel') throw new Error('cancel') const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalDislikeData()]) console.log('handleMergeListData', 'remoteListData, localListData') let listData: LX.Dislike.DislikeRules let requiredUpdateLocalListData = true let requiredUpdateRemoteListData = true switch (mode) { case 'merge_local_remote': listData = mergeList(socket, localListData, remoteListData) break case 'merge_remote_local': listData = mergeList(socket, remoteListData, localListData) break case 'overwrite_local_remote': listData = localListData requiredUpdateLocalListData = false break case 'overwrite_remote_local': listData = remoteListData requiredUpdateRemoteListData = false break // case 'none': return null // case 'cancel': default: throw new Error('cancel') } return [listData, requiredUpdateLocalListData, requiredUpdateRemoteListData] } const handleSyncList = async(socket: LX.Sync.Server.Socket) => { const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalDislikeData()]) console.log('handleSyncList', 'remoteListData, localListData') console.log('localListData', localListData.length) console.log('remoteListData', remoteListData.length) const userSpace = getUserSpace(socket.userInfo.name) const clientId = socket.keyInfo.clientId if (localListData.length) { if (remoteListData.length) { const [mergedList, requiredUpdateLocalListData, requiredUpdateRemoteListData] = await handleMergeListData(socket) console.log('handleMergeListData', 'mergedList', requiredUpdateLocalListData, requiredUpdateRemoteListData) let key if (requiredUpdateLocalListData) { key = await setLocalList(socket, mergedList) await overwriteRemoteListData(socket, mergedList, key, [clientId]) if (!requiredUpdateRemoteListData) await userSpace.dislikeManage.updateDeviceSnapshotKey(clientId, key) } if (requiredUpdateRemoteListData) { if (!key) key = await userSpace.dislikeManage.getCurrentListInfoKey() await setRemotelList(socket, mergedList, key) } } else { await setRemotelList(socket, localListData, await userSpace.dislikeManage.getCurrentListInfoKey()) } } else { let key: string if (remoteListData.length) { key = await setLocalList(socket, remoteListData) await overwriteRemoteListData(socket, remoteListData, key, [clientId]) } key ??= await userSpace.dislikeManage.getCurrentListInfoKey() await userSpace.dislikeManage.updateDeviceSnapshotKey(clientId, key) } } const mergeDataFromSnapshot = ( sourceList: LX.Dislike.DislikeRules, targetList: LX.Dislike.DislikeRules, snapshotList: LX.Dislike.DislikeRules, ): LX.Dislike.DislikeRules => { const removedRules = new Set() const sourceRules = filterRules(sourceList) const targetRules = filterRules(targetList) if (snapshotList) { const snapshotRules = filterRules(snapshotList) for (const m of snapshotRules.values()) { if (!sourceRules.has(m) || !targetRules.has(m)) removedRules.add(m) } } return Array.from(new Set(Array.from([...sourceRules, ...targetRules]).filter((rule) => { return !removedRules.has(rule) }))).join('\n') } const checkListLatest = async(socket: LX.Sync.Server.Socket) => { const remoteListMD5 = await getRemoteDataMD5(socket) const userSpace = getUserSpace(socket.userInfo.name) const userCurrentListInfoKey = await userSpace.dislikeManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) const currentListInfoKey = await userSpace.dislikeManage.getCurrentListInfoKey() const latest = remoteListMD5 == currentListInfoKey if (latest && userCurrentListInfoKey != currentListInfoKey) await userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, currentListInfoKey) return latest } const handleMergeListDataFromSnapshot = async(socket: LX.Sync.Server.Socket, snapshot: LX.Dislike.DislikeRules) => { if (await checkListLatest(socket)) return const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalDislikeData()]) const newDislikeData = mergeDataFromSnapshot(localListData, remoteListData, snapshot) const key = await setLocalList(socket, newDislikeData) const err = await setRemotelList(socket, newDislikeData, key).catch(err => err) await overwriteRemoteListData(socket, newDislikeData, key, [socket.keyInfo.clientId]) if (err) throw err } const syncDislike = async(socket: LX.Sync.Server.Socket) => { // socket.data.snapshotFilePath = getSnapshotFilePath(socket.keyInfo) // console.log(socket.keyInfo) if (!socket.feature.dislike) throw new Error('dislike feature options not available') if (!socket.feature.dislike.skipSnapshot) { const user = getUserSpace(socket.userInfo.name) const userCurrentDislikeInfoKey = await user.dislikeManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) if (userCurrentDislikeInfoKey) { const listData = await user.dislikeManage.snapshotDataManage.getSnapshot(userCurrentDislikeInfoKey) if (listData) { console.log('handleMergeDislikeDataFromSnapshot') await handleMergeListDataFromSnapshot(socket, listData) return } } } await handleSyncList(socket) } export const sync = async(socket: LX.Sync.Server.Socket) => { let disconnected = false socket.onClose(() => { disconnected = true if (syncingId == socket.keyInfo.clientId) syncingId = null }) while (true) { if (disconnected) throw new Error('disconnected') if (!syncingId) break await wait() } syncingId = socket.keyInfo.clientId await syncDislike(socket).then(async() => { await finishedSync(socket) socket.moduleReadys.dislike = true }).finally(() => { syncingId = null }) } ================================================ FILE: src/main/modules/sync/server/modules/dislike/utils.ts ================================================ import { SPLIT_CHAR } from '@common/constants' export const filterRules = (rules: string) => { const list: string[] = [] for (const item of rules.split('\n')) { if (!item) continue let [name, singer] = item.split(SPLIT_CHAR.DISLIKE_NAME) if (name) { name = name.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() list.push(`${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}`) } else { list.push(name) } } else if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() list.push(`${SPLIT_CHAR.DISLIKE_NAME}${singer}`) } } return new Set(list) } ================================================ FILE: src/main/modules/sync/server/modules/index.ts ================================================ import { sync as listSync } from './list' import { sync as dislikeSync } from './dislike' export const callObj = Object.assign({}, listSync.handler, dislikeSync.handler, ) export const modules = { list: listSync, dislike: dislikeSync, } export { ListManage } from './list' export { DislikeManage } from './dislike' export const featureVersion = { list: 1, dislike: 1, } as const ================================================ FILE: src/main/modules/sync/server/modules/list/index.ts ================================================ export * as sync from './sync' export { ListManage } from './manage' ================================================ FILE: src/main/modules/sync/server/modules/list/manage.ts ================================================ import { type UserDataManage } from '../../user' import { SnapshotDataManage } from './snapshotDataManage' import { toMD5 } from '../../utils' import { getLocalListData } from '@main/modules/sync/listEvent' export class ListManage { snapshotDataManage: SnapshotDataManage constructor(userDataManage: UserDataManage) { this.snapshotDataManage = new SnapshotDataManage(userDataManage) } createSnapshot = async() => { const listData = JSON.stringify(await this.getListData()) const md5 = toMD5(listData) const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() console.log(md5, snapshotInfo.latest) if (snapshotInfo.latest == md5) return md5 if (snapshotInfo.list.includes(md5)) { snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) } else await this.snapshotDataManage.saveSnapshot(md5, listData) if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) snapshotInfo.latest = md5 snapshotInfo.time = Date.now() this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) return md5 } getCurrentListInfoKey = async() => { // const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() // if (snapshotInfo.latest) { // return snapshotInfo.latest // } // snapshotInfo.latest = toMD5(JSON.stringify(await this.getListData())) // this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) // return snapshotInfo.latest return this.createSnapshot() } getDeviceCurrentSnapshotKey = async(clientId: string) => { return this.snapshotDataManage.getDeviceCurrentSnapshotKey(clientId) } updateDeviceSnapshotKey = async(clientId: string, key: string) => { await this.snapshotDataManage.updateDeviceSnapshotKey(clientId, key) } removeDevice = async(clientId: string) => { this.snapshotDataManage.removeSnapshotInfo(clientId) } getListData = async(): Promise => { return getLocalListData() } } ================================================ FILE: src/main/modules/sync/server/modules/list/snapshotDataManage.ts ================================================ import { throttle } from '@common/utils/common' import fs from 'node:fs' import path from 'node:path' import syncLog from '../../../log' import { getUserConfig, type UserDataManage } from '../../user/data' import { File } from '../../../../../../common/constants_sync' import { checkAndCreateDirSync } from '../../utils' interface SnapshotInfo { latest: string | null time: number list: string[] clients: Record } export class SnapshotDataManage { userDataManage: UserDataManage listDir: string snapshotDir: string snapshotInfoFilePath: string snapshotInfo: SnapshotInfo clientSnapshotKeys: string[] private readonly saveSnapshotInfoThrottle: () => void isIncluedsDevice = (key: string) => { return this.clientSnapshotKeys.includes(key) } clearOldSnapshot = async() => { if (!this.snapshotInfo) return const snapshotList = this.snapshotInfo.list.filter(key => !this.isIncluedsDevice(key)) // console.log(snapshotList.length, lx.config.maxSnapshotNum) const userMaxSnapshotNum = getUserConfig(this.userDataManage.userName).maxSnapshotNum let requiredSave = snapshotList.length > userMaxSnapshotNum while (snapshotList.length > userMaxSnapshotNum) { const name = snapshotList.pop() if (name) { await this.removeSnapshot(name) this.snapshotInfo.list.splice(this.snapshotInfo.list.indexOf(name), 1) } else break } if (requiredSave) this.saveSnapshotInfo(this.snapshotInfo) } updateDeviceSnapshotKey = async(clientId: string, key: string) => { // console.log('updateDeviceSnapshotKey', key) let client = this.snapshotInfo.clients[clientId] if (!client) client = this.snapshotInfo.clients[clientId] = { snapshotKey: '', lastSyncDate: 0 } if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) client.snapshotKey = key client.lastSyncDate = Date.now() this.clientSnapshotKeys.push(key) this.saveSnapshotInfoThrottle() } getDeviceCurrentSnapshotKey = async(clientId: string) => { // console.log('updateDeviceSnapshotKey', key) const client = this.snapshotInfo.clients[clientId] return client?.snapshotKey } getSnapshotInfo = async(): Promise => { return this.snapshotInfo } saveSnapshotInfo = (info: SnapshotInfo) => { this.snapshotInfo = info this.saveSnapshotInfoThrottle() } removeSnapshotInfo = (clientId: string) => { let client = this.snapshotInfo.clients[clientId] if (!client) return if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.snapshotInfo.clients[clientId] this.saveSnapshotInfoThrottle() } getSnapshot = async(name: string) => { const filePath = path.join(this.snapshotDir, `snapshot_${name}`) let listData: LX.Sync.List.ListData try { listData = JSON.parse((await fs.promises.readFile(filePath)).toString('utf-8')) } catch (err) { syncLog.warn(err) return null } return listData } saveSnapshot = async(name: string, data: string) => { syncLog.info('saveSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { await fs.promises.writeFile(filePath, data) } catch (err) { syncLog.error(err) throw err } } removeSnapshot = async(name: string) => { syncLog.info('removeSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { await fs.promises.unlink(filePath) } catch (err) { syncLog.error(err) } } constructor(userDataManage: UserDataManage) { this.userDataManage = userDataManage this.listDir = path.join(userDataManage.userDir, File.listDir) checkAndCreateDirSync(this.listDir) this.snapshotDir = path.join(this.listDir, File.listSnapshotDir) checkAndCreateDirSync(this.snapshotDir) this.snapshotInfoFilePath = path.join(this.listDir, File.listSnapshotInfoJSON) this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath) ? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString()) : { latest: null, time: 0, list: [], clients: {} } this.saveSnapshotInfoThrottle = throttle(() => { fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { if (err) console.error(err) void this.clearOldSnapshot() }) }) this.clientSnapshotKeys = Object.values(this.snapshotInfo.clients).map(device => device.snapshotKey).filter(k => k) } } // type UserDataManages = Map // export const createUserDataManage = (user: LX.UserConfig) => { // const manage = Object.create(userDataManage) as typeof userDataManage // manage.userDir = user.dataPath // } ================================================ FILE: src/main/modules/sync/server/modules/list/sync/handler.ts ================================================ // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 // import { throttle } from '@common/utils/common' // import { sendSyncActionList } from '@main/modules/winMain' // import { SYNC_CLOSE_CODE } from '@/constants' // import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { getUserSpace } from '@main/modules/sync/server/user' import { handleRemoteListAction } from '@main/modules/sync/listEvent' // import { encryptMsg } from '@/utils/tools' // let wss: LX.SocketServer | null // let removeListener: (() => void) | null // type listAction = 'list:action' // const registerListActionEvent = () => { // const list_data_overwrite = async(listData: MakeOptional, isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_data_overwrite', data: listData }) // } // const list_create = async(position: number, listInfos: LX.List.UserListInfo[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_create', data: { position, listInfos } }) // } // const list_remove = async(ids: string[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_remove', data: ids }) // } // const list_update = async(lists: LX.List.UserListInfo[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_update', data: lists }) // } // const list_update_position = async(position: number, ids: string[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_update_position', data: { position, ids } }) // } // const list_music_overwrite = async(listId: string, musicInfos: LX.Music.MusicInfo[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_overwrite', data: { listId, musicInfos } }) // } // const list_music_add = async(id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_add', data: { id, musicInfos, addMusicLocationType } }) // } // const list_music_move = async(fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_move', data: { fromId, toId, musicInfos, addMusicLocationType } }) // } // const list_music_remove = async(listId: string, ids: string[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_remove', data: { listId, ids } }) // } // const list_music_update = async(musicInfos: LX.List.ListActionMusicUpdate, isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_update', data: musicInfos }) // } // const list_music_clear = async(ids: string[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_clear', data: ids }) // } // const list_music_update_position = async(listId: string, position: number, ids: string[], isRemote: boolean = false) => { // if (isRemote) return // await sendListAction({ action: 'list_music_update_position', data: { listId, position, ids } }) // } // global.event_list.on('list_data_overwrite', list_data_overwrite) // global.event_list.on('list_create', list_create) // global.event_list.on('list_remove', list_remove) // global.event_list.on('list_update', list_update) // global.event_list.on('list_update_position', list_update_position) // global.event_list.on('list_music_overwrite', list_music_overwrite) // global.event_list.on('list_music_add', list_music_add) // global.event_list.on('list_music_move', list_music_move) // global.event_list.on('list_music_remove', list_music_remove) // global.event_list.on('list_music_update', list_music_update) // global.event_list.on('list_music_clear', list_music_clear) // global.event_list.on('list_music_update_position', list_music_update_position) // return () => { // global.event_list.off('list_data_overwrite', list_data_overwrite) // global.event_list.off('list_create', list_create) // global.event_list.off('list_remove', list_remove) // global.event_list.off('list_update', list_update) // global.event_list.off('list_update_position', list_update_position) // global.event_list.off('list_music_overwrite', list_music_overwrite) // global.event_list.off('list_music_add', list_music_add) // global.event_list.off('list_music_move', list_music_move) // global.event_list.off('list_music_remove', list_music_remove) // global.event_list.off('list_music_update', list_music_update) // global.event_list.off('list_music_clear', list_music_clear) // global.event_list.off('list_music_update_position', list_music_update_position) // } // } // const addMusic = (orderId, callback) => { // // ... // } // const broadcast = async(socket: LX.Socket, key: string, data: any, excludeIds: string[] = []) => { // if (!wss) return // const dataStr = JSON.stringify({ action: 'list:sync:action', data }) // const userSpace = getUserSpace(socket.userInfo.name) // for (const client of wss.clients) { // if (excludeIds.includes(client.keyInfo.clientId) || !client.isReady || client.userInfo.name != socket.userInfo.name) continue // client.send(encryptMsg(client.keyInfo, dataStr), (err) => { // if (err) { // client.close(SYNC_CLOSE_CODE.failed) // return // } // userSpace.dataManage.updateDeviceSnapshotKey(client.keyInfo, key) // }) // } // } // export const sendListAction = async(action: LX.Sync.List.ActionList) => { // console.log('sendListAction', action.action) // // io.sockets // await broadcast('list:sync:action', action) // } // export const registerListHandler = (_wss: LX.SocketServer, socket: LX.Socket) => { // if (!wss) { // wss = _wss // // removeListener = registerListActionEvent() // } // const userSpace = getUserSpace(socket.userInfo.name) // socket.onRemoteEvent('list:sync:action', (action) => { // if (!socket.isReady) return // // console.log(msg) // void handleListAction(socket.userInfo.name, action).then(key => { // if (!key) return // console.log(key) // userSpace.dataManage.updateDeviceSnapshotKey(socket.keyInfo, key) // void broadcast(socket, key, action, [socket.keyInfo.clientId]) // }) // // socket.broadcast.emit('list:action', { action: 'list_remove', data: { id: 'default', index: 0 } }) // }) // // socket.on('list:add', addMusic) // } // export const unregisterListHandler = () => { // wss = null // // if (removeListener) { // // removeListener() // // removeListener = null // // } // } const handler: LX.Sync.ServerSyncHandlerListActions = { async onListSyncAction(socket, action) { if (!socket.moduleReadys.list) return await handleRemoteListAction(action) const userSpace = getUserSpace(socket.userInfo.name) const key = await userSpace.listManage.createSnapshot() userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) const currentUserName = socket.userInfo.name const currentId = socket.keyInfo.clientId socket.broadcast((client) => { if (client.keyInfo.clientId == currentId || !client.moduleReadys?.list || client.userInfo.name != currentUserName) return void client.remoteQueueList.onListSyncAction(action).then(async() => { return userSpace.listManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.list = false console.log(err.message) }) }) }, } export default handler ================================================ FILE: src/main/modules/sync/server/modules/list/sync/index.ts ================================================ export { default as handler } from './handler' export { sync } from './sync' export * from './localEvent' ================================================ FILE: src/main/modules/sync/server/modules/list/sync/localEvent.ts ================================================ import { SYNC_CLOSE_CODE } from '@common/constants_sync' import { registerListActionEvent } from '../../../../listEvent' import { getUserSpace } from '../../../user' // let socket: LX.Sync.Server.Socket | null let unregisterLocalListAction: (() => void) | null const sendListAction = async(wss: LX.Sync.Server.SocketServer, action: LX.Sync.List.ActionList) => { // console.log('sendListAction', action.action) const userSpace = getUserSpace() let key = '' for (const client of wss.clients) { if (!client.moduleReadys?.list) continue // eslint-disable-next-line require-atomic-updates if (!key) key = await userSpace.listManage.createSnapshot() void client.remoteQueueList.onListSyncAction(action).then(async() => { return userSpace.listManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.list = false console.log(err.message) }) } } export const registerEvent = (wss: LX.Sync.Server.SocketServer) => { // socket = _socket // socket.onClose(() => { // unregisterLocalListAction?.() // unregisterLocalListAction = null // }) unregisterEvent() unregisterLocalListAction = registerListActionEvent((action) => { void sendListAction(wss, action) }) } export const unregisterEvent = () => { unregisterLocalListAction?.() unregisterLocalListAction = null } ================================================ FILE: src/main/modules/sync/server/modules/list/sync/sync.ts ================================================ // import { SYNC_CLOSE_CODE } from '../../../../constants' import { removeSelectModeListener, sendCloseSelectMode, sendSelectMode } from '@main/modules/winMain' import { getUserSpace, getUserConfig } from '../../../user' import { buildUserListInfoFull, getLocalListData, setLocalListData } from '@main/modules/sync/listEvent' import { SYNC_CLOSE_CODE } from '@common/constants_sync' // import { LIST_IDS } from '@common/constants' // type ListInfoType = LX.List.UserListInfoFull | LX.List.MyDefaultListInfoFull | LX.List.MyLoveListInfoFull // let wss: LX.Sync.Server.SocketServer | null let syncingId: string | null = null const wait = async(time = 1000) => await new Promise((resolve, reject) => setTimeout(resolve, time)) const patchListData = (listData: Partial): LX.Sync.List.ListData => { return Object.assign({ defaultList: [], loveList: [], userList: [], }, listData) } const getRemoteListData = async(socket: LX.Sync.Server.Socket): Promise => { console.log('getRemoteListData') return patchListData(await socket.remoteQueueList.list_sync_get_list_data()) } const getRemoteListMD5 = async(socket: LX.Sync.Server.Socket): Promise => { return socket.remoteQueueList.list_sync_get_md5() } // const getLocalListData = async(socket: LX.Sync.Server.Socket): Promise => { // return getUserSpace(socket.userInfo.name).listManage.getListData() // } const getSyncMode = async(socket: LX.Sync.Server.Socket): Promise => new Promise((resolve, reject) => { const handleDisconnect = (err: Error) => { sendCloseSelectMode() removeSelectModeListener() reject(err) } let removeEventClose = socket.onClose(handleDisconnect) sendSelectMode(socket.keyInfo.deviceName, 'list', (mode) => { if (mode == null) { reject(new Error('cancel')) return } resolve(mode) removeSelectModeListener() removeEventClose() }) }) // const getSyncMode = async(socket: LX.Sync.Server.Socket): Promise => { // return socket.remoteQueueList.list_sync_get_sync_mode() // } const finishedSync = async(socket: LX.Sync.Server.Socket) => { await socket.remoteQueueList.list_sync_finished() } const setLocalList = async(socket: LX.Sync.Server.Socket, listData: LX.Sync.List.ListData) => { await setLocalListData(listData) const userSpace = getUserSpace(socket.userInfo.name) return userSpace.listManage.createSnapshot() } const overwriteRemoteListData = async(socket: LX.Sync.Server.Socket, listData: LX.Sync.List.ListData, key: string, excludeIds: string[] = []) => { const action = { action: 'list_data_overwrite', data: listData } as const const tasks: Array> = [] const userSpace = getUserSpace(socket.userInfo.name) socket.broadcast((client) => { if (excludeIds.includes(client.keyInfo.clientId) || client.userInfo.name != socket.userInfo.name || !client.moduleReadys?.list) return tasks.push(client.remoteQueueList.onListSyncAction(action).then(async() => { return userSpace.listManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) }).catch(err => { // TODO send status client.close(SYNC_CLOSE_CODE.failed) // client.moduleReadys.list = false console.log(err.message) })) }) if (!tasks.length) return await Promise.all(tasks) } const setRemotelList = async(socket: LX.Sync.Server.Socket, listData: LX.Sync.List.ListData, key: string): Promise => { await socket.remoteQueueList.list_sync_set_list_data(listData) const userSpace = getUserSpace(socket.userInfo.name) await userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) } type UserDataObj = Map const createUserListDataObj = (listData: LX.Sync.List.ListData): UserDataObj => { const userListDataObj: UserDataObj = new Map() for (const list of listData.userList) userListDataObj.set(list.id, list) return userListDataObj } const handleMergeList = ( sourceList: LX.Music.MusicInfo[], targetList: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, ): LX.Music.MusicInfo[] => { let newList switch (addMusicLocationType) { case 'top': newList = [...targetList, ...sourceList] break case 'bottom': default: newList = [...sourceList, ...targetList] break } const map = new Map() const ids: Array = [] switch (addMusicLocationType) { case 'top': newList = [...targetList, ...sourceList] for (let i = newList.length - 1; i > -1; i--) { const item = newList[i] if (map.has(item.id)) continue ids.unshift(item.id) map.set(item.id, item) } break case 'bottom': default: newList = [...sourceList, ...targetList] for (const item of newList) { if (map.has(item.id)) continue ids.push(item.id) map.set(item.id, item) } break } return ids.map(id => map.get(id)) as LX.Music.MusicInfo[] } const mergeList = (socket: LX.Sync.Server.Socket, sourceListData: LX.Sync.List.ListData, targetListData: LX.Sync.List.ListData): LX.Sync.List.ListData => { const addMusicLocationType = getUserConfig(socket.userInfo.name)['list.addMusicLocationType'] const newListData: LX.Sync.List.ListData = { defaultList: [], loveList: [], userList: [], } newListData.defaultList = handleMergeList(sourceListData.defaultList, targetListData.defaultList, addMusicLocationType) newListData.loveList = handleMergeList(sourceListData.loveList, targetListData.loveList, addMusicLocationType) const userListDataObj = createUserListDataObj(sourceListData) newListData.userList = [...sourceListData.userList] targetListData.userList.forEach((list, index) => { const targetUpdateTime = list?.locationUpdateTime ?? 0 const sourceList = userListDataObj.get(list.id) if (sourceList) { sourceList.list = handleMergeList(sourceList.list, list.list, addMusicLocationType) const sourceUpdateTime = sourceList?.locationUpdateTime ?? 0 if (targetUpdateTime >= sourceUpdateTime) return // 调整位置 const [newList] = newListData.userList.splice(newListData.userList.findIndex(l => l.id == list.id), 1) newList.locationUpdateTime = targetUpdateTime newListData.userList.splice(index, 0, newList) } else { if (targetUpdateTime) { newListData.userList.splice(index, 0, list) } else { newListData.userList.push(list) } } }) return newListData } const overwriteList = (sourceListData: LX.Sync.List.ListData, targetListData: LX.Sync.List.ListData): LX.Sync.List.ListData => { const newListData: LX.Sync.List.ListData = { defaultList: [], loveList: [], userList: [], } newListData.defaultList = sourceListData.defaultList newListData.loveList = sourceListData.loveList const userListDataObj = createUserListDataObj(sourceListData) newListData.userList = [...sourceListData.userList] targetListData.userList.forEach((list, index) => { if (userListDataObj.has(list.id)) return if (list?.locationUpdateTime) { newListData.userList.splice(index, 0, list) } else { newListData.userList.push(list) } }) return newListData } const handleMergeListData = async(socket: LX.Sync.Server.Socket): Promise<[LX.Sync.List.ListData, boolean, boolean]> => { const mode: LX.Sync.List.SyncMode = await getSyncMode(socket) if (mode == 'cancel') throw new Error('cancel') const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData()]) console.log('handleMergeListData', 'remoteListData, localListData') let listData: LX.Sync.List.ListData let requiredUpdateLocalListData = true let requiredUpdateRemoteListData = true switch (mode) { case 'merge_local_remote': listData = mergeList(socket, localListData, remoteListData) break case 'merge_remote_local': listData = mergeList(socket, remoteListData, localListData) break case 'overwrite_local_remote': listData = overwriteList(localListData, remoteListData) break case 'overwrite_remote_local': listData = overwriteList(remoteListData, localListData) break case 'overwrite_local_remote_full': listData = localListData requiredUpdateLocalListData = false break case 'overwrite_remote_local_full': listData = remoteListData requiredUpdateRemoteListData = false break // case 'none': return null // case 'cancel': default: throw new Error('cancel') } return [listData, requiredUpdateLocalListData, requiredUpdateRemoteListData] } const handleSyncList = async(socket: LX.Sync.Server.Socket) => { const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData()]) console.log('handleSyncList', 'remoteListData, localListData') console.log('localListData', localListData.defaultList.length || localListData.loveList.length || localListData.userList.length) console.log('remoteListData', remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) const userSpace = getUserSpace(socket.userInfo.name) const clientId = socket.keyInfo.clientId if (localListData.defaultList.length || localListData.loveList.length || localListData.userList.length) { if (remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) { const [mergedList, requiredUpdateLocalListData, requiredUpdateRemoteListData] = await handleMergeListData(socket) console.log('handleMergeListData', 'mergedList', requiredUpdateLocalListData, requiredUpdateRemoteListData) let key if (requiredUpdateLocalListData) { key = await setLocalList(socket, mergedList) await overwriteRemoteListData(socket, mergedList, key, [clientId]) if (!requiredUpdateRemoteListData) await userSpace.listManage.updateDeviceSnapshotKey(clientId, key) } if (requiredUpdateRemoteListData) { if (!key) key = await userSpace.listManage.getCurrentListInfoKey() await setRemotelList(socket, mergedList, key) } } else { await setRemotelList(socket, localListData, await userSpace.listManage.getCurrentListInfoKey()) } } else { let key: string if (remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) { key = await setLocalList(socket, remoteListData) await overwriteRemoteListData(socket, remoteListData, key, [clientId]) } key ??= await userSpace.listManage.getCurrentListInfoKey() await userSpace.listManage.updateDeviceSnapshotKey(clientId, key) } } const mergeListDataFromSnapshot = ( sourceList: LX.Music.MusicInfo[], targetList: LX.Music.MusicInfo[], snapshotList: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, ): LX.Music.MusicInfo[] => { const removedListIds = new Set() const sourceListItemIds = new Set() const targetListItemIds = new Set() for (const m of sourceList) sourceListItemIds.add(m.id) for (const m of targetList) targetListItemIds.add(m.id) if (snapshotList) { for (const m of snapshotList) { if (!sourceListItemIds.has(m.id) || !targetListItemIds.has(m.id)) removedListIds.add(m.id) } } let newList const map = new Map() const ids = [] switch (addMusicLocationType) { case 'top': newList = [...targetList, ...sourceList] for (let i = newList.length - 1; i > -1; i--) { const item = newList[i] if (map.has(item.id) || removedListIds.has(item.id)) continue ids.unshift(item.id) map.set(item.id, item) } break case 'bottom': default: newList = [...sourceList, ...targetList] for (const item of newList) { if (map.has(item.id) || removedListIds.has(item.id)) continue ids.push(item.id) map.set(item.id, item) } break } return ids.map(id => map.get(id)) as LX.Music.MusicInfo[] } const checkListLatest = async(socket: LX.Sync.Server.Socket) => { const remoteListMD5 = await getRemoteListMD5(socket) const userSpace = getUserSpace(socket.userInfo.name) const userCurrentListInfoKey = await userSpace.listManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) const currentListInfoKey = await userSpace.listManage.getCurrentListInfoKey() const latest = remoteListMD5 == currentListInfoKey if (latest && userCurrentListInfoKey != currentListInfoKey) await userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, currentListInfoKey) return latest } const selectData = (snapshot: T | null, local: T, remote: T): T => { return snapshot == local ? remote // ? (snapshot == remote ? snapshot as T : remote) : local } const handleMergeListDataFromSnapshot = async(socket: LX.Sync.Server.Socket, snapshot: LX.Sync.List.ListData) => { if (await checkListLatest(socket)) return const addMusicLocationType = getUserConfig(socket.userInfo.name)['list.addMusicLocationType'] const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData()]) const newListData: LX.Sync.List.ListData = { defaultList: [], loveList: [], userList: [], } newListData.defaultList = mergeListDataFromSnapshot(localListData.defaultList, remoteListData.defaultList, snapshot.defaultList, addMusicLocationType) newListData.loveList = mergeListDataFromSnapshot(localListData.loveList, remoteListData.loveList, snapshot.loveList, addMusicLocationType) const localUserListData = createUserListDataObj(localListData) const remoteUserListData = createUserListDataObj(remoteListData) const snapshotUserListData = createUserListDataObj(snapshot) const removedListIds = new Set() const localUserListIds = new Set() const remoteUserListIds = new Set() for (const l of localListData.userList) localUserListIds.add(l.id) for (const l of remoteListData.userList) remoteUserListIds.add(l.id) for (const l of snapshot.userList) { if (!localUserListIds.has(l.id) || !remoteUserListIds.has(l.id)) removedListIds.add(l.id) } let newUserList: LX.List.UserListInfoFull[] = [] for (const list of localListData.userList) { if (removedListIds.has(list.id)) continue const remoteList = remoteUserListData.get(list.id) let newList: LX.List.UserListInfoFull if (remoteList) { const snapshotList = snapshotUserListData.get(list.id) ?? { name: null, source: null, sourceListId: null, list: [] } newList = buildUserListInfoFull({ id: list.id, name: selectData(snapshotList.name, list.name, remoteList.name), source: selectData(snapshotList.source, list.source, remoteList.source), sourceListId: selectData(snapshotList.sourceListId, list.sourceListId, remoteList.sourceListId), locationUpdateTime: list.locationUpdateTime, list: mergeListDataFromSnapshot(list.list, remoteList.list, snapshotList.list, addMusicLocationType), }) } else { newList = { ...list } } newUserList.push(newList) } remoteListData.userList.forEach((list, index) => { if (removedListIds.has(list.id)) return const remoteUpdateTime = list?.locationUpdateTime ?? 0 if (localUserListData.has(list.id)) { const localUpdateTime = localUserListData.get(list.id)?.locationUpdateTime ?? 0 if (localUpdateTime >= remoteUpdateTime) return // 调整位置 const [newList] = newUserList.splice(newUserList.findIndex(l => l.id == list.id), 1) newList.locationUpdateTime = localUpdateTime newUserList.splice(index, 0, newList) } else { if (remoteUpdateTime) { newUserList.splice(index, 0, { ...list }) } else { newUserList.push({ ...list }) } } }) newListData.userList = newUserList const key = await setLocalList(socket, newListData) const err = await setRemotelList(socket, newListData, key).catch(err => err) await overwriteRemoteListData(socket, newListData, key, [socket.keyInfo.clientId]) if (err) throw err } const syncList = async(socket: LX.Sync.Server.Socket) => { // socket.data.snapshotFilePath = getSnapshotFilePath(socket.keyInfo) // console.log(socket.keyInfo) if (!socket.feature.list) throw new Error('list feature options not available') if (!socket.feature.list.skipSnapshot) { const user = getUserSpace(socket.userInfo.name) const userCurrentListInfoKey = await user.listManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) if (userCurrentListInfoKey) { const listData = await user.listManage.snapshotDataManage.getSnapshot(userCurrentListInfoKey) if (listData) { console.log('handleMergeListDataFromSnapshot') await handleMergeListDataFromSnapshot(socket, listData) return } } } await handleSyncList(socket) } // export default async(_wss: LX.Sync.Server.SocketServer, socket: LX.Sync.Server.Socket) => { // if (!wss) { // wss = _wss // _wss.addListener('close', () => { // wss = null // }) // } // let disconnected = false // socket.onClose(() => { // disconnected = true // if (syncingId == socket.keyInfo.clientId) syncingId = null // }) // while (true) { // if (disconnected) throw new Error('disconnected') // if (!syncingId) break // await wait() // } // syncingId = socket.keyInfo.clientId // await syncList(socket).then(async() => { // return finishedSync(socket) // }).finally(() => { // syncingId = null // }) // } // const removeSnapshot = async(keyInfo: LX.Sync.KeyInfo) => { // const filePath = getSnapshotFilePath(keyInfo) // await fsPromises.unlink(filePath) // } export const sync = async(socket: LX.Sync.Server.Socket) => { let disconnected = false socket.onClose(() => { disconnected = true if (syncingId == socket.keyInfo.clientId) syncingId = null }) while (true) { if (disconnected) throw new Error('disconnected') if (!syncingId) break await wait() } syncingId = socket.keyInfo.clientId await syncList(socket).then(async() => { await finishedSync(socket) socket.moduleReadys.list = true }).finally(() => { syncingId = null }) } ================================================ FILE: src/main/modules/sync/server/server/auth.ts ================================================ import type http from 'http' import { aesEncrypt, aesDecrypt, rsaEncrypt, getIP, } from '../utils/tools' import querystring from 'node:querystring' import { getUserSpace, createClientKeyInfo } from '../user' import { toMD5 } from '../utils' import { getComputerName } from '../../utils' import { SYNC_CODE } from '@common/constants_sync' const requestIps = new Map() const getAvailableIP = (req: http.IncomingMessage) => { let ip = getIP(req) return ip && (requestIps.get(ip) ?? 0) < 10 ? ip : null } const verifyByKey = (encryptMsg: string, userId: string) => { const userSpace = getUserSpace() const keyInfo = userSpace.dataManage.getClientKeyInfo(userId) if (!keyInfo) return null let text try { text = aesDecrypt(encryptMsg, keyInfo.key) } catch (err) { return null } // console.log(text) if (text.startsWith(SYNC_CODE.authMsg)) { const deviceName = text.replace(SYNC_CODE.authMsg, '') || 'Unknown' if (deviceName != keyInfo.deviceName) { keyInfo.deviceName = deviceName userSpace.dataManage.saveClientKeyInfo(keyInfo) } return aesEncrypt(SYNC_CODE.helloMsg, keyInfo.key) } return null } const verifyByCode = (encryptMsg: string, password: string) => { let key = toMD5(password).substring(0, 16) // const iv = Buffer.from(key.split('').reverse().join('')).toString('base64') key = Buffer.from(key).toString('base64') // console.log(req.headers.m, authCode, key) let text try { text = aesDecrypt(encryptMsg, key) } catch { return null } // console.log(text) if (text.startsWith(SYNC_CODE.authMsg)) { const data = text.split('\n') const publicKey = `-----BEGIN PUBLIC KEY-----\n${data[1]}\n-----END PUBLIC KEY-----` const deviceName = data[2] || 'Unknown' const isMobile = data[3] == 'lx_music_mobile' const keyInfo = createClientKeyInfo(deviceName, isMobile) const userSpace = getUserSpace() userSpace.dataManage.saveClientKeyInfo(keyInfo) return rsaEncrypt(Buffer.from(JSON.stringify({ clientId: keyInfo.clientId, key: keyInfo.key, serverName: getComputerName(), })), publicKey) } return null } export const authCode = async(req: http.IncomingMessage, res: http.ServerResponse, password: string) => { let code = 401 let msg: string = SYNC_CODE.msgAuthFailed let ip = getAvailableIP(req) if (ip) { if (typeof req.headers.m == 'string' && req.headers.m) { const userId = req.headers.i const _msg = typeof userId == 'string' && userId ? verifyByKey(req.headers.m, userId) : verifyByCode(req.headers.m, password) if (_msg != null) { msg = _msg code = 200 } } if (code != 200) { const num = requestIps.get(ip) ?? 0 // if (num > 20) return requestIps.set(ip, num + 1) } } else { code = 403 msg = SYNC_CODE.msgBlockedIp } // console.log(req.headers) res.writeHead(code) res.end(msg) } const verifyConnection = (encryptMsg: string, userId: string) => { const userSpace = getUserSpace() const keyInfo = userSpace.dataManage.getClientKeyInfo(userId) if (!keyInfo) return false let text try { text = aesDecrypt(encryptMsg, keyInfo.key) } catch (err) { return false } // console.log(text) return text == SYNC_CODE.msgConnect } export const authConnect = async(req: http.IncomingMessage) => { let ip = getAvailableIP(req) if (ip) { const query = querystring.parse((req.url!).split('?')[1]) const i = query.i const t = query.t if (typeof i == 'string' && typeof t == 'string' && verifyConnection(t, i)) return const num = requestIps.get(ip) ?? 0 requestIps.set(ip, num + 1) } throw new Error('failed') } ================================================ FILE: src/main/modules/sync/server/server/index.ts ================================================ export { startServer, stopServer, getStatus, generateCode, getDevices, removeDevice, } from './server' ================================================ FILE: src/main/modules/sync/server/server/server.ts ================================================ import http, { type IncomingMessage } from 'node:http' import { WebSocketServer } from 'ws' import { registerLocalSyncEvent, callObj, sync, unregisterLocalSyncEvent } from './sync' import { authCode, authConnect } from './auth' import { SYNC_CLOSE_CODE, SYNC_CODE } from '@common/constants_sync' import { getUserSpace, releaseUserSpace, getServerId, initServerInfo } from '../user' import { createMsg2call } from 'message2call' import log from '../../log' import { sendServerStatus } from '@main/modules/winMain' import { decryptMsg, encryptMsg, generateCode as handleGenerateCode } from '../utils/tools' import migrateData from '../../migrate' import type { Socket } from 'node:net' import { getAddress } from '@common/utils/nodejs' let status: LX.Sync.ServerStatus = { status: false, message: '', address: [], code: '', devices: [], } let stopingServer = false let host = 'http://localhost' const codeTools: { timeout: NodeJS.Timeout | null start: () => void stop: () => void } = { timeout: null, start() { this.stop() this.timeout = setInterval(() => { void handleGenerateCode() }, 60 * 3 * 1000) }, stop() { if (!this.timeout) return clearInterval(this.timeout) this.timeout = null }, } const checkDuplicateClient = (newSocket: LX.Sync.Server.Socket) => { for (const client of [...wss!.clients]) { if (client === newSocket || client.keyInfo.clientId != newSocket.keyInfo.clientId) continue log.info('duplicate client', client.userInfo.name, client.keyInfo.deviceName) client.isReady = false for (const name of Object.keys(client.moduleReadys) as Array) { client.moduleReadys[name] = false } client.close(SYNC_CLOSE_CODE.normal) } } const handleConnection = async(socket: LX.Sync.Server.Socket, request: IncomingMessage) => { const queryData = new URL(request.url!, host).searchParams const clientId = queryData.get('i') // // if (typeof socket.handshake.query.i != 'string') return socket.disconnect(true) const userSpace = getUserSpace() const keyInfo = userSpace.dataManage.getClientKeyInfo(clientId) if (!keyInfo) { socket.close(SYNC_CLOSE_CODE.failed) return } keyInfo.lastConnectDate = Date.now() userSpace.dataManage.saveClientKeyInfo(keyInfo) // // socket.lx_keyInfo = keyInfo socket.keyInfo = keyInfo socket.userInfo = { name: 'default' } checkDuplicateClient(socket) try { await sync(socket) } catch (err) { // console.log(err) log.warn(err) return } status.devices.push(keyInfo) // handleConnection(io, socket) sendServerStatus(status) socket.onClose(() => { status.devices.splice(status.devices.findIndex(k => k.clientId == keyInfo.clientId), 1) sendServerStatus(status) }) // console.log('connection', keyInfo.deviceName) log.info('connection', keyInfo.deviceName) // console.log(socket.handshake.query) socket.isReady = true } const handleUnconnection = () => { // console.log('unconnection') releaseUserSpace() } const authConnection = (req: http.IncomingMessage, callback: (err: string | null | undefined, success: boolean) => void) => { // console.log(req.headers) // // console.log(req.auth) // console.log(req._query.authCode) authConnect(req).then(() => { callback(null, true) }).catch(err => { callback(err, false) }) } let wss: LX.Sync.Server.SocketServer | null let httpServer: http.Server let sockets = new Set() function noop() {} function onSocketError(err: Error) { console.error(err) } const handleStartServer = async(port = 9527, ip = '0.0.0.0') => await new Promise((resolve, reject) => { httpServer = http.createServer((req, res) => { // console.log(req.url) const endUrl = `/${req.url?.split('/').at(-1) ?? ''}` let code let msg switch (endUrl) { case '/hello': code = 200 msg = SYNC_CODE.helloMsg break case '/id': code = 200 msg = SYNC_CODE.idPrefix + getServerId() break case '/ah': void authCode(req, res, status.code) break default: code = 401 msg = 'Forbidden' break } if (!code) return res.writeHead(code) res.end(msg) }) wss = new WebSocketServer({ noServer: true, }) wss.on('connection', function(socket, request) { socket.isReady = false socket.moduleReadys = { list: false, dislike: false, } socket.feature = { list: false, dislike: false, } socket.on('pong', () => { socket.isAlive = true }) // const events = new Map void>>() // const events = new Map void>>() // let events: Partial<{ [K in keyof LX.Sync.ActionSyncType]: Array<(data: LX.Sync.ActionSyncType[K]) => void> }> = {} let closeEvents: Array<(err: Error) => (void | Promise)> = [] let disconnected = false const msg2call = createMsg2call({ funcsObj: callObj, timeout: 120 * 1000, sendMessage(data) { if (disconnected) throw new Error('disconnected') void encryptMsg(socket.keyInfo, JSON.stringify(data)).then((data) => { // console.log('sendData', eventName) socket.send(data) }).catch(err => { log.error('encrypt message error:', err) log.error(err.message) socket.close(SYNC_CLOSE_CODE.failed) }) }, onCallBeforeParams(rawArgs) { return [socket, ...rawArgs] }, onError(error, path, groupName) { const name = groupName ?? '' const deviceName = socket.keyInfo?.deviceName ?? '' log.error(`sync call ${deviceName} ${name} ${path.join('.')} error:`, error) // if (groupName == null) return // socket.close(SYNC_CLOSE_CODE.failed) }, }) socket.remote = msg2call.remote socket.remoteQueueList = msg2call.createQueueRemote('list') socket.remoteQueueDislike = msg2call.createQueueRemote('dislike') socket.addEventListener('message', ({ data }) => { if (typeof data != 'string') return void decryptMsg(socket.keyInfo, data).then((data) => { let syncData: any try { syncData = JSON.parse(data) } catch (err) { log.error('parse message error:', err) socket.close(SYNC_CLOSE_CODE.failed) return } msg2call.message(syncData) }).catch(err => { log.error('decrypt message error:', err) log.error(err.message) socket.close(SYNC_CLOSE_CODE.failed) }) }) socket.addEventListener('close', () => { const err = new Error('closed') try { for (const handler of closeEvents) void handler(err) } catch (err: any) { log.error(err?.message) } closeEvents = [] disconnected = true msg2call.destroy() if (socket.isReady) { log.info('deconnection', socket.userInfo.name, socket.keyInfo.deviceName) // events = {} if (!status.devices.length) handleUnconnection() } else { const queryData = new URL(request.url!, host).searchParams log.info('deconnection', queryData.get('i')) } }) socket.onClose = function(handler: typeof closeEvents[number]) { closeEvents.push(handler) return () => { closeEvents.splice(closeEvents.indexOf(handler), 1) } } socket.broadcast = function(handler) { if (!wss) return for (const client of wss.clients) handler(client) } void handleConnection(socket, request) }) httpServer.on('upgrade', function upgrade(request, socket, head) { socket.addListener('error', onSocketError) // This function is not defined on purpose. Implement it with your own logic. authConnection(request, err => { if (err) { console.log(err) socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy() return } socket.removeListener('error', onSocketError) wss?.handleUpgrade(request, socket, head, function done(ws) { wss?.emit('connection', ws, request) }) }) }) const interval = setInterval(() => { wss?.clients.forEach(socket => { if (socket.isAlive == false) { log.info('alive check false:', socket.userInfo.name, socket.keyInfo.deviceName) socket.terminate() return } socket.isAlive = false socket.ping(noop) if (socket.keyInfo.isMobile) socket.send('ping', noop) }) }, 30000) wss.on('close', function close() { clearInterval(interval) }) httpServer.on('error', error => { console.log(error) reject(error) }) httpServer.on('connection', (socket) => { sockets.add(socket) socket.once('close', () => { sockets.delete(socket) }) }) httpServer.on('listening', () => { const addr = httpServer.address() // console.log(addr) if (!addr) { reject(new Error('address is null')) return } const bind = typeof addr == 'string' ? `pipe ${addr}` : `port ${addr.port}` log.info(`Listening on ${ip} ${bind}`) resolve(null) void registerLocalSyncEvent(wss!) }) host = `http://${ip}:${port}` httpServer.listen(port, ip) }) const handleStopServer = async() => new Promise((resolve, reject) => { if (!wss) return for (const client of wss.clients) client.close(SYNC_CLOSE_CODE.normal) unregisterLocalSyncEvent() wss.close() wss = null httpServer.close((err) => { if (err) { reject(err) return } resolve() }) for (const socket of sockets) socket.destroy() sockets.clear() }) export const stopServer = async() => { codeTools.stop() if (!status.status) { status.status = false status.message = '' status.address = [] status.code = '' sendServerStatus(status) return } console.log('stoping sync server...') status.message = 'stoping...' sendServerStatus(status) stopingServer = true await handleStopServer().then(() => { console.log('sync server stoped') status.status = false status.message = '' status.address = [] status.code = '' }).catch(err => { console.log(err) status.message = err.message }).finally(() => { sendServerStatus(status) stopingServer = false }) } export const startServer = async(port: number) => { // if (status.status) await handleStopServer() console.log('status.status', status.status, stopingServer) if (stopingServer) return if (status.status) await handleStopServer() await migrateData(global.lxDataPath) await initServerInfo() log.info('starting sync server') await handleStartServer(port).then(() => { console.log('sync server started') status.status = true status.message = '' status.address = getAddress() void generateCode() codeTools.start() }).catch(err => { console.log(err) status.status = false status.message = err.message status.address = [] status.code = '' }).finally(() => { sendServerStatus(status) }) } export const getStatus = (): LX.Sync.ServerStatus => status export const generateCode = async() => { status.code = handleGenerateCode() sendServerStatus(status) return status.code } export const getDevices = async() => { const userSpace = getUserSpace() return userSpace.getDecices() } export const removeDevice = async(clientId: string) => { if (wss) { for (const client of wss.clients) { if (client.keyInfo.clientId == clientId) client.close(SYNC_CLOSE_CODE.normal) } } const userSpace = getUserSpace() await userSpace.removeDevice(clientId) } ================================================ FILE: src/main/modules/sync/server/server/sync/event.ts ================================================ import { modules } from '../../modules' export const registerLocalSyncEvent = async(wss: LX.Sync.Server.SocketServer) => { unregisterLocalSyncEvent() for (const module of Object.values(modules)) { module.registerEvent(wss) } } export const unregisterLocalSyncEvent = () => { for (const module of Object.values(modules)) { module.unregisterEvent() } } ================================================ FILE: src/main/modules/sync/server/server/sync/handler.ts ================================================ // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 // import { getUserSpace } from '@/user' import { FeaturesList } from '../../../../../../common/constants_sync' import { modules } from '../../modules' const handler: LX.Sync.ServerSyncHandlerActions = { async onFeatureChanged(socket, feature) { // const userSpace = getUserSpace(socket.userInfo.name) const beforeFeature = socket.feature for (const name of FeaturesList) { const newStatus = feature[name] if (newStatus == null) continue beforeFeature[name] = feature[name] socket.moduleReadys[name] = false if (feature[name]) await modules[name].sync(socket).catch(_ => _) } }, } export default handler ================================================ FILE: src/main/modules/sync/server/server/sync/index.ts ================================================ import handler from './handler' import { callObj as _callObj } from '../../modules' export { sync } from './sync' export { modules } from '../../modules' export * from './event' export const callObj = { ...handler, ..._callObj, } ================================================ FILE: src/main/modules/sync/server/server/sync/sync.ts ================================================ import { FeaturesList } from '../../../../../../common/constants_sync' import { featureVersion, modules } from '../../modules' export const sync = async(socket: LX.Sync.Server.Socket) => { let disconnected = false socket.onClose(() => { disconnected = true }) const enabledFeatures = await socket.remote.getEnabledFeatures('desktop-app', featureVersion) if (disconnected) throw new Error('disconnected') for (const moduleName of FeaturesList) { if (enabledFeatures[moduleName]) { socket.feature[moduleName] = enabledFeatures[moduleName] await modules[moduleName].sync(socket).catch(_ => _) } if (disconnected) throw new Error('disconnected') } await socket.remote.finished() } ================================================ FILE: src/main/modules/sync/server/user/data.ts ================================================ import fs from 'node:fs' import path from 'node:path' import { randomBytes } from 'node:crypto' import { throttle } from '@common/utils/common' import { filterFileName, toMD5 } from '../utils' import { File } from '@common/constants_sync' import { exists } from '../../utils' interface ServerInfo { serverId: string version: number } interface DevicesInfo { userName: string clients: Record } const saveServerInfoThrottle = throttle(() => { fs.writeFile(path.join(global.lxDataPath, File.serverDataPath, File.serverInfoJSON), JSON.stringify(serverInfo), (err) => { if (err) console.error(err) }) }) let serverInfo: ServerInfo export const initServerInfo = async() => { if (serverInfo != null) return const serverInfoFilePath = path.join(global.lxDataPath, File.serverDataPath, File.serverInfoJSON) if (await exists(serverInfoFilePath)) { // eslint-disable-next-line require-atomic-updates serverInfo = JSON.parse((await fs.promises.readFile(serverInfoFilePath)).toString()) } else { // eslint-disable-next-line require-atomic-updates serverInfo = { serverId: randomBytes(4 * 4).toString('base64'), version: 2, } const syncDataPath = path.join(global.lxDataPath, File.serverDataPath) if (!await exists(syncDataPath)) { await fs.promises.mkdir(syncDataPath, { recursive: true }) } saveServerInfoThrottle() } } export const getServerId = () => { return serverInfo.serverId } export const getVersion = async() => { await initServerInfo() return serverInfo.version ?? 1 } export const setVersion = async(version: number) => { await initServerInfo() serverInfo.version = version saveServerInfoThrottle() } export const getUserDirname = (userName: string) => `${filterFileName(userName)}_${toMD5(userName).substring(0, 6)}` export const getUserConfig = (userName: string) => { return { maxSnapshotNum: global.lx.appSetting['sync.server.maxSsnapshotNum'], 'list.addMusicLocationType': global.lx.appSetting['list.addMusicLocationType'], } } // 读取所有用户目录下的devicesInfo信息,建立clientId与用户的对应关系,用于非首次连接 // let deviceUserMap: Map = new Map() // const init // for (const deviceInfo of fs.readdirSync(syncDataPath).map(dirname => { // const devicesFilePath = path.join(syncDataPath, dirname, File.userDevicesJSON) // if (fs.existsSync(devicesFilePath)) { // const devicesInfo = JSON.parse(fs.readFileSync(devicesFilePath).toString()) as DevicesInfo // if (getUserDirname(devicesInfo.userName) == dirname) return { userName: devicesInfo.userName, devices: devicesInfo.clients } // } // return { userName: '', devices: {} } // })) { // for (const device of Object.values(deviceInfo.devices)) { // if (deviceInfo.userName) deviceUserMap.set(device.clientId, deviceInfo.userName) // } // } // export const getUserName = (clientId: string): string | null => { // if (!clientId) return null // return deviceUserMap.get(clientId) ?? null // } // export const setUserName = (clientId: string, dir: string) => { // deviceUserMap.set(clientId, dir) // } // export const deleteUserName = (clientId: string) => { // deviceUserMap.delete(clientId) // } export const createClientKeyInfo = (deviceName: string, isMobile: boolean): LX.Sync.ServerKeyInfo => { const keyInfo: LX.Sync.ServerKeyInfo = { clientId: randomBytes(4 * 4).toString('base64'), key: randomBytes(16).toString('base64'), deviceName, isMobile, lastConnectDate: 0, } return keyInfo } export class UserDataManage { userName: string userDir: string devicesFilePath: string devicesInfo: DevicesInfo private readonly saveDevicesInfoThrottle: () => void getAllClientKeyInfo = () => { return Object.values(this.devicesInfo.clients).sort((a, b) => (b.lastConnectDate ?? 0) - (a.lastConnectDate ?? 0)) } saveClientKeyInfo = (keyInfo: LX.Sync.ServerKeyInfo) => { if (this.devicesInfo.clients[keyInfo.clientId] == null && Object.keys(this.devicesInfo.clients).length > 101) throw new Error('max keys') this.devicesInfo.clients[keyInfo.clientId] = keyInfo this.saveDevicesInfoThrottle() } getClientKeyInfo = (clientId?: string | null): LX.Sync.ServerKeyInfo | null => { if (!clientId) return null return this.devicesInfo.clients[clientId] ?? null } removeClientKeyInfo = async(clientId: string) => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.devicesInfo.clients[clientId] this.saveDevicesInfoThrottle() } isIncluedsClient = (clientId: string) => { return Object.values(this.devicesInfo.clients).some(client => client.clientId == clientId) } constructor(userName: string) { this.userName = userName const syncDataPath = path.join(global.lxDataPath, File.serverDataPath) this.userDir = syncDataPath this.devicesFilePath = path.join(this.userDir, File.userDevicesJSON) this.devicesInfo = fs.existsSync(this.devicesFilePath) ? JSON.parse(fs.readFileSync(this.devicesFilePath).toString()) : { userName, clients: {} } this.saveDevicesInfoThrottle = throttle(() => { fs.writeFile(this.devicesFilePath, JSON.stringify(this.devicesInfo), 'utf8', (err) => { if (err) console.error(err) }) }) } } // type UserDataManages = Map // export const createUserDataManage = (user: LX.UserConfig) => { // const manage = Object.create(userDataManage) as typeof userDataManage // manage.userDir = user.dataPath // } ================================================ FILE: src/main/modules/sync/server/user/index.ts ================================================ import { UserDataManage } from './data' import { ListManage, DislikeManage, } from '../modules' export interface UserSpace { dataManage: UserDataManage listManage: ListManage dislikeManage: DislikeManage getDecices: () => Promise removeDevice: (clientId: string) => Promise } const users = new Map() const delayTime = 10 * 1000 const delayReleaseTimeouts = new Map() const clearDelayReleaseTimeout = (userName: string) => { if (!delayReleaseTimeouts.has(userName)) return clearTimeout(delayReleaseTimeouts.get(userName)) delayReleaseTimeouts.delete(userName) } const seartDelayReleaseTimeout = (userName: string) => { clearDelayReleaseTimeout(userName) delayReleaseTimeouts.set(userName, setTimeout(() => { users.delete(userName) }, delayTime)) } export const getUserSpace = (userName = 'default') => { clearDelayReleaseTimeout(userName) let user = users.get(userName) if (!user) { console.log('new user data manage:', userName) const dataManage = new UserDataManage(userName) const listManage = new ListManage(dataManage) const dislikeManage = new DislikeManage(dataManage) users.set(userName, user = { dataManage, listManage, dislikeManage, async getDecices() { return this.dataManage.getAllClientKeyInfo() }, async removeDevice(clientId) { await listManage.removeDevice(clientId) await dataManage.removeClientKeyInfo(clientId) }, }) } return user } export const releaseUserSpace = (userName = 'default', force = false) => { if (force) { clearDelayReleaseTimeout(userName) users.delete(userName) } else seartDelayReleaseTimeout(userName) } export * from './data' ================================================ FILE: src/main/modules/sync/server/utils/index.ts ================================================ import fs from 'node:fs' import crypto from 'node:crypto' export const createDirSync = (path: string) => { if (!fs.existsSync(path)) { try { fs.mkdirSync(path, { recursive: true }) } catch (e: any) { if (e.code !== 'EEXIST') { console.error('Could not set up log directory, error was: ', e) process.exit(1) } } } } const fileNameRxp = /[\\/:*?#"<>|]/g export const filterFileName = (name: string): string => name.replace(fileNameRxp, '') /** * 创建 MD5 hash * @param {*} str */ export const toMD5 = (str: string) => crypto.createHash('md5').update(str).digest('hex') export const checkAndCreateDirSync = (path: string) => { if (!fs.existsSync(path)) { fs.mkdirSync(path, { recursive: true }) } } ================================================ FILE: src/main/modules/sync/server/utils/tools.ts ================================================ import { createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants } from 'node:crypto' // import { join } from 'node:path' import zlib from 'node:zlib' import type http from 'node:http' // import getStore from '@/utils/store' // import syncLog from '../../log' // import { getUserName } from '../user/data' // import { saveClientKeyInfo } from './data' export const generateCode = (): string => { return Math.random().toString().substring(2, 8) } export const getIP = (request: http.IncomingMessage) => { return request.socket.remoteAddress } export const aesEncrypt = (buffer: string | Buffer, key: string): string => { const cipher = createCipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') return Buffer.concat([cipher.update(buffer), cipher.final()]).toString('base64') } export const aesDecrypt = (text: string, key: string): string => { const decipher = createDecipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') return Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString() } export const rsaEncrypt = (buffer: Buffer, key: string): string => { return publicEncrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer).toString('base64') } export const rsaDecrypt = (buffer: Buffer, key: string): Buffer => { return privateDecrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer) } const gzip = async(data: string) => new Promise((resolve, reject) => { zlib.gzip(data, (err, buf) => { if (err) { reject(err) return } resolve(buf.toString('base64')) }) }) const unGzip = async(data: string) => new Promise((resolve, reject) => { zlib.gunzip(Buffer.from(data, 'base64'), (err, buf) => { if (err) { reject(err) return } resolve(buf.toString()) }) }) export const encryptMsg = async(keyInfo: LX.Sync.ServerKeyInfo | null, msg: string): Promise => { return msg.length > 1024 ? 'cg_' + await gzip(msg) : msg // if (!keyInfo) return '' // return aesEncrypt(msg, keyInfo.key, keyInfo.iv) } export const decryptMsg = async(keyInfo: LX.Sync.ServerKeyInfo | null, enMsg: string): Promise => { return enMsg.substring(0, 3) == 'cg_' ? await unGzip(enMsg.replace('cg_', '')) : enMsg // console.log('decmsg raw: ', len.length, 'en: ', enMsg.length) // if (!keyInfo) return '' // let msg = '' // try { // msg = aesDecrypt(enMsg, keyInfo.key, keyInfo.iv) // } catch (err) { // console.log(err) // } // return msg } // export const getSnapshotFilePath = (keyInfo: LX.Sync.KeyInfo): string => { // return join(global.lx.snapshotPath, `snapshot_${keyInfo.snapshotKey}.json`) // } // export const sendStatus = (status: LX.Sync.ServerStatus) => { // syncLog.info('status', status.devices.map(d => `${getUserName(d.clientId) ?? ''} ${d.deviceName}`)) // } ================================================ FILE: src/main/modules/sync/utils.ts ================================================ import { createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants } from 'node:crypto' import os from 'node:os' import fs from 'node:fs' import zlib from 'node:zlib' import cp from 'node:child_process' // https://stackoverflow.com/a/75309339 export const getComputerName = () => { let name: string | undefined switch (process.platform) { case 'win32': name = process.env.COMPUTERNAME break case 'darwin': try { name = cp.execSync('scutil --get ComputerName').toString().trim() } catch {} break case 'linux': // Don't fail even if hostnamectl is unavailable try { name = cp.execSync('hostnamectl --pretty').toString().trim() } catch {} break } if (!name) name = os.hostname() return name } const gzip = async(data: string) => new Promise((resolve, reject) => { zlib.gzip(data, (err, buf) => { if (err) { reject(err) return } resolve(buf.toString('base64')) }) }) const unGzip = async(data: string) => new Promise((resolve, reject) => { zlib.gunzip(Buffer.from(data, 'base64'), (err, buf) => { if (err) { reject(err) return } resolve(buf.toString()) }) }) export const encodeData = async(data: string) => { return data.length > 1024 ? 'cg_' + await gzip(data) : data } export const decodeData = async(enData: string) => { return enData.substring(0, 3) == 'cg_' ? await unGzip(enData.replace('cg_', '')) : enData } export const aesEncrypt = (text: string, key: string) => { const cipher = createCipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') return Buffer.concat([cipher.update(Buffer.from(text)), cipher.final()]).toString('base64') } export const aesDecrypt = (text: string, key: string) => { const decipher = createDecipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') return Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString() } export const rsaEncrypt = (buffer: Buffer, key: string): string => { return publicEncrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer).toString('base64') } export const rsaDecrypt = (buffer: Buffer, key: string): Buffer => { return privateDecrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer) } export const exists = async(path: string) => fs.promises.stat(path).then(() => true).catch(() => false) ================================================ FILE: src/main/modules/tray.ts ================================================ import { Tray, Menu, nativeImage } from 'electron' import { isMac, isWin } from '@common/utils' import path from 'node:path' import { hideWindow as hideMainWindow, isExistWindow as isExistMainWindow, isShowWindow as isShowMainWindow, sendTaskbarButtonClick, showWindow as showMainWindow, } from './winMain' import { quitApp } from '@main/app' import { TRAY_AUTO_ID } from '@common/constants' let tray: Electron.Tray | null let isEnableTray: boolean = false let themeId: number let isShowStatusBarLyric: boolean = false const playerState = { empty: false, collect: false, play: false, next: true, prev: true, } const watchConfigKeys = [ 'desktopLyric.enable', 'desktopLyric.isLock', 'desktopLyric.isAlwaysOnTop', 'tray.themeId', 'tray.enable', 'player.isShowStatusBarLyric', 'common.langId', ] satisfies Array const themeList = [ { id: 0, fileName: 'trayTemplate', isNative: true, }, { id: 1, fileName: 'tray_origin', isNative: false, }, { id: 2, fileName: 'tray_black', isNative: false, }, ] const messages = { 'en-us': { collect: 'Love', uncollect: 'Unlove', play: 'Play', pause: 'Pause', next: 'Next Song', prev: 'Prev Song', hide_win_main: 'Hide Main Window', show_win_main: 'Show Main Window', hide_win_lyric: 'Hide Lyric Window', show_win_lyric: 'Show Lyric Window', lock_win_lyric: 'Lock Lyric Window', unlock_win_lyric: 'Unlock Lyric Window', top_win_lyric: 'On-top Lyric Window', untop_win_lyric: 'Un-top Lyric Window', show_statusbar_lyric: 'Show Lyrics on Statusbar', hide_statusbar_lyric: 'Hide Lyrics on Statusbar', exit: 'Exit', music_name: 'Title: ', music_singer: 'Artist: ', }, 'zh-cn': { collect: '收藏', uncollect: '取消收藏', play: '播放', pause: '暂停', next: '下一曲', prev: '上一曲', hide_win_main: '隐藏主界面', show_win_main: '显示主界面', hide_win_lyric: '关闭桌面歌词', show_win_lyric: '开启桌面歌词', lock_win_lyric: '锁定桌面歌词', unlock_win_lyric: '解锁桌面歌词', top_win_lyric: '置顶歌词', untop_win_lyric: '取消置顶', show_statusbar_lyric: '显示状态栏歌词', hide_statusbar_lyric: '隐藏状态栏歌词', exit: '退出', music_name: '歌曲名: ', music_singer: '艺术家: ', }, 'zh-tw': { collect: '收藏', uncollect: '取消收藏', play: '播放', pause: '暫停', next: '下一曲', prev: '上一曲', hide_win_main: '隱藏軟體視窗', show_win_main: '顯示軟體視窗', hide_win_lyric: '關閉歌詞視窗', show_win_lyric: '開啟歌詞視窗', lock_win_lyric: '鎖定歌詞視窗', unlock_win_lyric: '解鎖歌詞視窗', top_win_lyric: '置頂歌詞視窗', untop_win_lyric: '取消置頂歌詞視窗', show_statusbar_lyric: '顯示狀態列歌詞', hide_statusbar_lyric: '隱藏狀態列歌詞', exit: '退出', music_name: '標題: ', music_singer: '演出者: ', }, } as const type Messages = typeof messages type Langs = keyof Messages const i18n = { message: messages['zh-cn'] as Messages[Langs], fallbackLocale: 'en-us' as 'en-us', getMessage(key: keyof Messages[Langs]) { return this.message[key] }, setLang(lang?: Langs | null) { this.message = lang ? messages[lang] ?? messages[this.fallbackLocale] : messages[this.fallbackLocale] }, } const getIconPath = (id: number) => { let theme = id == TRAY_AUTO_ID ? global.lx.theme.shouldUseDarkColors ? themeList[0] : themeList[2] : themeList.find(item => item.id === id) ?? themeList[0] return path.join(global.staticPath, 'images/tray', theme.fileName + (isWin ? '.ico' : '.png')) } export const createTray = () => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if ((tray && !tray.isDestroyed()) || !global.lx.appSetting['tray.enable']) return // 托盘 tray = new Tray(nativeImage.createFromPath(getIconPath(global.lx.appSetting['tray.themeId']))) // tray.setToolTip('LX Music') // createMenu() tray.setIgnoreDoubleClickEvents(true) if (isWin) { tray.on('click', () => { showMainWindow() }) } } export const destroyTray = () => { if (!tray) return tray.destroy() isEnableTray = false isShowStatusBarLyric = false tray = null } const handleUpdateConfig = (setting: Partial) => { global.lx.event_app.update_config(setting) } const createPlayerMenu = () => { let menu: Electron.MenuItemConstructorOptions[] = [] menu.push(playerState.play ? { label: i18n.getMessage('pause'), click() { sendTaskbarButtonClick('pause') }, } : { label: i18n.getMessage('play'), click() { sendTaskbarButtonClick('play') }, }) menu.push({ label: i18n.getMessage('prev'), click() { sendTaskbarButtonClick('prev') }, }) menu.push({ label: i18n.getMessage('next'), click() { sendTaskbarButtonClick('next') }, }) menu.push(playerState.collect ? { label: i18n.getMessage('uncollect'), click() { sendTaskbarButtonClick('unCollect') }, } : { label: i18n.getMessage('collect'), click() { sendTaskbarButtonClick('collect') }, }) return menu } export const createMenu = () => { if (!tray) return let menu: Electron.MenuItemConstructorOptions[] = createPlayerMenu() if (playerState.empty) for (const m of menu) m.enabled = false menu.push({ type: 'separator' }) menu.push(global.lx.appSetting['desktopLyric.enable'] ? { label: i18n.getMessage('hide_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.enable': false }) }, } : { label: i18n.getMessage('show_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.enable': true }) }, }) menu.push(global.lx.appSetting['desktopLyric.isLock'] ? { label: i18n.getMessage('unlock_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.isLock': false }) }, } : { label: i18n.getMessage('lock_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.isLock': true }) }, }) menu.push(global.lx.appSetting['desktopLyric.isAlwaysOnTop'] ? { label: i18n.getMessage('untop_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.isAlwaysOnTop': false }) }, } : { label: i18n.getMessage('top_win_lyric'), click() { handleUpdateConfig({ 'desktopLyric.isAlwaysOnTop': true }) }, }) if (isMac) { menu.push({ type: 'separator' }) menu.push(isShowStatusBarLyric ? { label: i18n.getMessage('hide_statusbar_lyric'), click() { handleUpdateConfig({ 'player.isShowStatusBarLyric': false }) }, } : { label: i18n.getMessage('show_statusbar_lyric'), click() { handleUpdateConfig({ 'player.isShowStatusBarLyric': true }) }, }) } menu.push({ type: 'separator' }) if (isExistMainWindow()) { const isShow = isShowMainWindow() menu.push(isShow ? { label: i18n.getMessage('hide_win_main'), click() { hideMainWindow() }, } : { label: i18n.getMessage('show_win_main'), click() { showMainWindow() }, }) } menu.push({ label: i18n.getMessage('exit'), click() { quitApp() }, }) const contextMenu = Menu.buildFromTemplate(menu) tray.setContextMenu(contextMenu) } export const setTrayImage = (themeId: number) => { if (!tray) return tray.setImage(nativeImage.createFromPath(getIconPath(themeId))) } const setLyric = (lyricLineText?: string) => { if (isShowStatusBarLyric && tray && lyricLineText != null) { tray.setTitle(lyricLineText) } } const defaultTip = 'LX Music' const setTip = () => { if (!tray) return let name = global.lx.player_status.name let tip: string if (name) { if (name.length > 20) name = name.substring(0, 20) + '...' let singer = global.lx.player_status.singer if (singer?.length > 20) singer = singer.substring(0, 20) + '...' tip = `${defaultTip}\n${i18n.getMessage('music_name')}${name}${singer ? `\n${i18n.getMessage('music_singer')}${singer}` : ''}` } else tip = defaultTip tray.setToolTip(tip) } const init = () => { if (themeId != global.lx.appSetting['tray.themeId']) { themeId = global.lx.appSetting['tray.themeId'] setTrayImage(themeId) } if (isEnableTray !== global.lx.appSetting['tray.enable']) { isEnableTray = global.lx.appSetting['tray.enable'] global.lx.appSetting['tray.enable'] ? createTray() : destroyTray() } if (isShowStatusBarLyric !== global.lx.appSetting['player.isShowStatusBarLyric']) { isShowStatusBarLyric = global.lx.appSetting['player.isShowStatusBarLyric'] if (isShowStatusBarLyric) { setLyric(global.lx.player_status.lyricLineText) } else { tray?.setTitle('') } } setTip() createMenu() } export default () => { global.lx.event_app.on('updated_config', (keys, setting) => { if (!watchConfigKeys.some(key => keys.includes(key))) return if (keys.includes('common.langId')) i18n.setLang(setting['common.langId']) init() }) global.lx.event_app.on('main_window_ready_to_show', () => { createMenu() }) global.lx.event_app.on('main_window_show', () => { createMenu() }) if (!isWin) { global.lx.event_app.on('main_window_focus', () => { createMenu() }) global.lx.event_app.on('main_window_blur', () => { createMenu() }) } global.lx.event_app.on('main_window_hide', () => { createMenu() }) global.lx.event_app.on('main_window_close', () => { destroyTray() }) global.lx.event_app.on('app_inited', () => { i18n.setLang(global.lx.appSetting['common.langId']) init() }) global.lx.event_app.on('system_theme_change', () => { if (global.lx.appSetting['tray.themeId'] != TRAY_AUTO_ID) return setTrayImage(global.lx.appSetting['tray.themeId']) }) global.lx.event_app.on('player_status', (status) => { let updated = false if (status.status) { switch (status.status) { case 'paused': playerState.play = false playerState.empty &&= false setLyric('') break case 'error': playerState.play = false playerState.empty &&= false setLyric('') break case 'playing': playerState.play = true playerState.empty &&= false setLyric(global.lx.player_status.lyricLineText) break case 'stoped': playerState.play &&= false playerState.empty = true setLyric('') break } updated = true } else { setLyric(status.lyricLineText) } if (status.name != null) setTip() if (status.singer != null) setTip() if (status.collect != null) { playerState.collect = status.collect updated = true } if (updated) init() }) } ================================================ FILE: src/main/modules/userApi/config/index.ts ================================================ export const userApis: LX.UserApi.UserApiInfoFull[] = [] ================================================ FILE: src/main/modules/userApi/index.ts ================================================ import { closeWindow } from './main' import { getUserApis, importApi as handleImportApi, removeApi as handleRemoveApi, setAllowShowUpdateAlert as saveAllowShowUpdateAlert } from './utils' import { loadApi, setAllowShowUpdateAlert as setRendererEventAllowShowUpdateAlert, init } from './rendererEvent/rendererEvent' let userApiId: string | null export const getApiList = getUserApis export const importApi = async(script: string): Promise => { return { apiInfo: await handleImportApi(script), apiList: getUserApis(), } } export const removeApi = async(ids: string[]): Promise => { if (userApiId && ids.includes(userApiId)) { userApiId = null await closeWindow() } handleRemoveApi(ids) return getUserApis() } export const setApi = async(id: string) => { if (userApiId) { userApiId = null await closeWindow() } const apiList = getUserApis() if (!apiList.some(a => a.id === id)) return userApiId ||= id await loadApi(id) } export const setAllowShowUpdateAlert = (id: string, enable: boolean) => { saveAllowShowUpdateAlert(id, enable) setRendererEventAllowShowUpdateAlert(id, enable) } export * from './rendererEvent/rendererEvent' export default () => { init() global.lx.event_app.on('main_window_close', () => { void closeWindow() }) } ================================================ FILE: src/main/modules/userApi/main.ts ================================================ import { mainSend } from '@common/mainIpc' import { BrowserWindow } from 'electron' import fs from 'fs' import path from 'node:path' import { openDevTools as handleOpenDevTools } from '@main/utils' import USER_API_RENDERER_EVENT_NAME from './rendererEvent/name' import { getScript } from './utils' let browserWindow: Electron.BrowserWindow | null = null let html: string | null = null let dir: string | null = null const denyEvents = [ 'will-navigate', 'will-redirect', 'will-attach-webview', 'will-prevent-unload', 'media-started-playing', ] as const export const getProxy = () => { if (global.lx.appSetting['network.proxy.enable'] && global.lx.appSetting['network.proxy.host']) { return { host: global.lx.appSetting['network.proxy.host'], port: global.lx.appSetting['network.proxy.port'], } } const envProxy = envParams.cmdParams['proxy-server'] if (envProxy) { if (envProxy && typeof envProxy == 'string') { const [host, port = ''] = envProxy.split(':') return { host, port, } } } return { host: '', port: '', } } const handleUpdateProxy = (keys: Array) => { if (keys.includes('network.proxy.enable') || (global.lx.appSetting['network.proxy.enable'] && keys.some(k => k.startsWith('network.proxy.')))) { sendEvent(USER_API_RENDERER_EVENT_NAME.proxyUpdate, getProxy()) } } const winEvent = () => { if (!browserWindow) return browserWindow.on('closed', () => { browserWindow = null }) } export const createWindow = async(userApi: LX.UserApi.UserApiInfo) => { await closeWindow() dir ??= process.env.NODE_ENV !== 'production' ? webpackUserApiPath : path.join(__dirname, 'userApi') if (!html) { // eslint-disable-next-line require-atomic-updates html = await fs.promises.readFile(path.join(dir, 'renderer/user-api.html'), 'utf8') } const preloadUrl = process.env.NODE_ENV !== 'production' ? `${path.join(__dirname, '../dist/user-api-preload.js')}` : `${path.join(__dirname, 'user-api-preload.js')}` // console.log(preloadUrl) /** * Initial window options */ browserWindow = new BrowserWindow({ // enableRemoteModule: false, resizable: false, minimizable: false, maximizable: false, fullscreenable: false, roundedCorners: false, hasShadow: false, show: false, webPreferences: { contextIsolation: true, // worldSafeExecuteJavaScript: true, nodeIntegration: false, nodeIntegrationInWorker: false, sandbox: false, spellcheck: false, autoplayPolicy: 'document-user-activation-required', enableWebSQL: false, disableDialogs: true, // nativeWindowOpen: false, webgl: false, images: false, preload: preloadUrl, }, }) for (const eventName of denyEvents) { // @ts-expect-error browserWindow.webContents.on(eventName, (event: Electron.Event) => { event.preventDefault() }) } browserWindow.webContents.session.setPermissionRequestHandler((webContents, permission, resolve) => { if (webContents === browserWindow?.webContents) { resolve(false) return } resolve(true) }) browserWindow.webContents.setWindowOpenHandler(() => { return { action: 'deny' } }) winEvent() // console.log(html.replace('', ``)) // const randomNum = Math.random().toString().substring(2, 10) await browserWindow.loadURL('data:text/html;charset=UTF-8,' + encodeURIComponent(html)) browserWindow.on('ready-to-show', async() => { global.lx.event_app.on('updated_config', handleUpdateProxy) sendEvent(USER_API_RENDERER_EVENT_NAME.initEnv, { ...userApi, script: await getScript(userApi.id), proxy: getProxy() }) }) // global.modules.userApiWindow.loadFile(join(dir, 'renderer/user-api.html')) // global.modules.userApiWindow.webContents.openDevTools() } export const closeWindow = async() => { global.lx.event_app.off('updated_config', handleUpdateProxy) if (!browserWindow) return await Promise.all([ browserWindow.webContents.session.clearAuthCache(), browserWindow.webContents.session.clearStorageData(), browserWindow.webContents.session.clearCache(), ]) browserWindow?.destroy() browserWindow = null } export const sendEvent = (name: string, params?: T) => { if (!browserWindow) return mainSend(browserWindow, name, params) } export const openDevTools = () => { if (!browserWindow) return handleOpenDevTools(browserWindow.webContents) } ================================================ FILE: src/main/modules/userApi/renderer/preload.js ================================================ import { contextBridge, ipcRenderer, webFrame } from 'electron' import needle from 'needle' import zlib from 'zlib' import { createCipheriv, publicEncrypt, constants, randomBytes, createHash } from 'crypto' import USER_API_RENDERER_EVENT_NAME from '../rendererEvent/name' import { httpOverHttp, httpsOverHttp } from 'tunnel' const sendMessage = (action, data, status, message) => { ipcRenderer.send(action, { data, status, message }) } let isInitedApi = false const proxy = { host: '', port: '', } let isShowedUpdateAlert = false const EVENT_NAMES = { request: 'request', inited: 'inited', updateAlert: 'updateAlert', } const eventNames = Object.values(EVENT_NAMES) const events = { request: null, } const allSources = ['kw', 'kg', 'tx', 'wy', 'mg', 'local'] const supportQualitys = { kw: ['128k', '320k', 'flac', 'flac24bit'], kg: ['128k', '320k', 'flac', 'flac24bit'], tx: ['128k', '320k', 'flac', 'flac24bit'], wy: ['128k', '320k', 'flac', 'flac24bit'], mg: ['128k', '320k', 'flac', 'flac24bit'], local: [], } const supportActions = { kw: ['musicUrl'], kg: ['musicUrl'], tx: ['musicUrl'], wy: ['musicUrl'], mg: ['musicUrl'], xm: ['musicUrl'], local: ['musicUrl', 'lyric', 'pic'], } const httpsRxp = /^https:/ const getRequestAgent = url => { return proxy.host ? (httpsRxp.test(url) ? httpsOverHttp : httpOverHttp)({ proxy: { host: proxy.host, port: proxy.port, }, }) : undefined } const verifyLyricInfo = (info) => { if (typeof info != 'object' || typeof info.lyric != 'string') throw new Error('failed') if (info.lyric.length > 51200) throw new Error('failed') return { lyric: info.lyric, tlyric: (typeof info.tlyric == 'string' && info.tlyric.length < 5120) ? info.tlyric : null, rlyric: (typeof info.rlyric == 'string' && info.rlyric.length < 5120) ? info.rlyric : null, lxlyric: (typeof info.lxlyric == 'string' && info.lxlyric.length < 8192) ? info.lxlyric : null, } } const handleRequest = (context, { requestKey, data }) => { // console.log(data) if (!events.request) return sendMessage(USER_API_RENDERER_EVENT_NAME.response, { requestKey }, false, 'Request event is not defined') try { events.request.call(context, { source: data.source, action: data.action, info: data.info }).then(response => { let sendData = { requestKey, } switch (data.action) { case 'musicUrl': if (typeof response != 'string' || response.length > 2048 || !/^https?:/.test(response)) throw new Error('failed') sendData.result = { source: data.source, action: data.action, data: { type: data.info.type, url: response, }, } break case 'lyric': sendData.result = { source: data.source, action: data.action, data: verifyLyricInfo(response), } break case 'pic': if (typeof response != 'string' || response.length > 2048 || !/^https?:/.test(response)) throw new Error('failed') sendData.result = { source: data.source, action: data.action, data: response, } break } sendMessage(USER_API_RENDERER_EVENT_NAME.response, sendData, true) }).catch(err => { sendMessage(USER_API_RENDERER_EVENT_NAME.response, { requestKey }, false, err.message) }) } catch (err) { sendMessage(USER_API_RENDERER_EVENT_NAME.response, { requestKey }, false, err.message) } } /** * * @param {*} context * @param {*} info { * openDevTools: false, * message: 'xxx', * sources: { * kw: ['128k', '320k', 'flac', 'flac24bit'], * kg: ['128k', '320k', 'flac', 'flac24bit'], * tx: ['128k', '320k', 'flac', 'flac24bit'], * wy: ['128k', '320k', 'flac', 'flac24bit'], * mg: ['128k', '320k', 'flac', 'flac24bit'], * } * } */ const handleInit = (context, info) => { if (!info) { sendMessage(USER_API_RENDERER_EVENT_NAME.init, null, false, 'Missing required parameter init info') // sendMessage(USER_API_RENDERER_EVENT_NAME.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '') return } if (info.openDevTools === true) { sendMessage(USER_API_RENDERER_EVENT_NAME.openDevTools) } // if (!info.status) { // sendMessage(USER_API_RENDERER_EVENT_NAME.init, null, false, 'Missing required parameter init info') // // sendMessage(USER_API_RENDERER_EVENT_NAME.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '') // return // } const sourceInfo = { sources: {}, } try { for (const source of allSources) { const userSource = info.sources[source] if (!userSource || userSource.type !== 'music') continue const qualitys = supportQualitys[source] const actions = supportActions[source] sourceInfo.sources[source] = { type: 'music', actions: actions.filter(a => userSource.actions.includes(a)), qualitys: qualitys.filter(q => userSource.qualitys.includes(q)), } } } catch (error) { console.log(error) sendMessage(USER_API_RENDERER_EVENT_NAME.init, null, false, error.message) return } sendMessage(USER_API_RENDERER_EVENT_NAME.init, sourceInfo, true) ipcRenderer.on(USER_API_RENDERER_EVENT_NAME.request, (event, data) => { handleRequest(context, data) }) } const handleShowUpdateAlert = (data, resolve, reject) => { if (!data || typeof data != 'object') return reject(new Error('parameter format error.')) if (!data.log || typeof data.log != 'string') return reject(new Error('log is required.')) if (data.updateUrl && !/^https?:\/\/[^\s$.?#].[^\s]*$/.test(data.updateUrl) && data.updateUrl.length > 1024) delete data.updateUrl if (data.log.length > 1024) data.log = data.log.substring(0, 1024) + '...' sendMessage(USER_API_RENDERER_EVENT_NAME.showUpdateAlert, { log: data.log, updateUrl: data.updateUrl, }) resolve() } const onError = (errorMessage) => { if (isInitedApi) return isInitedApi = true if (errorMessage.length > 1024) errorMessage = errorMessage.substring(0, 1024) + '...' sendMessage(USER_API_RENDERER_EVENT_NAME.init, null, false, errorMessage) } const initEnv = (userApi) => { proxy.host = userApi.proxy.host proxy.port = userApi.proxy.port contextBridge.exposeInMainWorld('lx', { EVENT_NAMES, request(url, { method = 'get', timeout, headers, body, form, formData }, callback) { let options = { headers, agent: getRequestAgent(url), } let data if (body) { data = body } else if (form) { data = form // data.content_type = 'application/x-www-form-urlencoded' options.json = false } else if (formData) { data = formData // data.content_type = 'multipart/form-data' options.json = false } options.response_timeout = typeof timeout == 'number' && timeout > 0 ? Math.min(timeout, 60_000) : 60_000 let request = needle.request(method, url, data, options, (err, resp, body) => { // console.log(err, resp, body) try { if (err) { callback.call(this, err, null, null) } else { body = resp.body = resp.raw.toString() try { resp.body = JSON.parse(resp.body) } catch (_) {} body = resp.body callback.call(this, err, { statusCode: resp.statusCode, statusMessage: resp.statusMessage, headers: resp.headers, bytes: resp.bytes, raw: resp.raw, body, }, body) } } catch (err) { onError(err.message) } }).request return () => { if (!request.aborted) request.abort() request = null } }, send(eventName, data) { return new Promise((resolve, reject) => { if (!eventNames.includes(eventName)) return reject(new Error('The event is not supported: ' + eventName)) switch (eventName) { case EVENT_NAMES.inited: if (isInitedApi) return reject(new Error('Script is inited')) isInitedApi = true handleInit(this, data) resolve() break case EVENT_NAMES.updateAlert: if (isShowedUpdateAlert) return reject(new Error('The update alert can only be called once.')) isShowedUpdateAlert = true handleShowUpdateAlert(data, resolve, reject) break default: reject(new Error('Unknown event name: ' + eventName)) } }) }, on(eventName, handler) { if (!eventNames.includes(eventName)) return Promise.reject(new Error('The event is not supported: ' + eventName)) switch (eventName) { case EVENT_NAMES.request: events.request = handler break default: return Promise.reject(new Error('The event is not supported: ' + eventName)) } return Promise.resolve() }, utils: { crypto: { aesEncrypt(buffer, mode, key, iv) { const cipher = createCipheriv(mode, key, iv) return Buffer.concat([cipher.update(buffer), cipher.final()]) }, rsaEncrypt(buffer, key) { buffer = Buffer.concat([Buffer.alloc(128 - buffer.length), buffer]) return publicEncrypt({ key, padding: constants.RSA_NO_PADDING }, buffer) }, randomBytes(size) { return randomBytes(size) }, md5(str) { return createHash('md5').update(str).digest('hex') }, }, buffer: { from(...args) { return Buffer.from(...args) }, bufToString(buf, format) { return Buffer.from(buf, 'binary').toString(format) }, }, zlib: { inflate(buf) { return new Promise((resolve, reject) => { zlib.inflate(buf, (err, data) => { if (err) reject(new Error(err.message)) else resolve(data) }) }) }, deflate(data) { return new Promise((resolve, reject) => { zlib.deflate(data, (err, buf) => { if (err) reject(new Error(err.message)) else resolve(buf) }) }) }, }, }, currentScriptInfo: { name: userApi.name, description: userApi.description, version: userApi.version, author: userApi.author, homepage: userApi.homepage, rawScript: userApi.script, }, version: '2.0.0', env: 'desktop', // removeEvent(eventName, handler) { // if (!eventNames.includes(eventName)) return Promise.reject(new Error('The event is not supported: ' + eventName)) // let handlers // switch (eventName) { // case EVENT_NAMES.request: // handlers = events.request // break // } // for (let index = 0; index < handlers.length; index++) { // if (handlers[index] === handler) { // handlers.splice(index, 1) // break // } // } // }, // removeAllEvents() { // for (const handlers of Object.values(events)) { // handlers.splice(0, handlers.length) // } // }, }) contextBridge.exposeInMainWorld('__lx_init_error_handler__', { sendError(errorMessage) { onError(errorMessage) }, }) webFrame.executeJavaScript(`(() => { window.addEventListener('error', (event) => { if (event.isTrusted) globalThis.__lx_init_error_handler__.sendError(event.message.replace(/^Uncaught\\sError:\\s/, '')) }) window.addEventListener('unhandledrejection', (event) => { if (!event.isTrusted) return const message = typeof event.reason === 'string' ? event.reason : event.reason?.message ?? String(event.reason) globalThis.__lx_init_error_handler__.sendError(message.replace(/^Error:\\s/, '')) }) })()`) webFrame.executeJavaScript(userApi.script).catch(_ => _) } ipcRenderer.on(USER_API_RENDERER_EVENT_NAME.initEnv, (event, data) => { initEnv(data) }) ipcRenderer.on(USER_API_RENDERER_EVENT_NAME.proxyUpdate, (event, data) => { proxy.host = data.host proxy.port = data.port }) ================================================ FILE: src/main/modules/userApi/renderer/user-api.html ================================================ User api ================================================ FILE: src/main/modules/userApi/rendererEvent/name.js ================================================ const names = { initEnv: '', init: '', request: '', response: '', openDevTools: '', showUpdateAlert: '', getProxy: '', proxyUpdate: '', } for (const key of Object.keys(names)) { names[key] = `userApi_${key}` } export default names ================================================ FILE: src/main/modules/userApi/rendererEvent/rendererEvent.ts ================================================ import { mainOn } from '@common/mainIpc' import USER_API_RENDERER_EVENT_NAME from './name' import { createWindow, getProxy, openDevTools, sendEvent } from '../main' import { getUserApis } from '../utils' import { sendShowUpdateAlert, sendStatusChange } from '@main/modules/winMain' let userApi: LX.UserApi.UserApiInfo let apiStatus: LX.UserApi.UserApiStatus = { status: true } const requestQueue = new Map() const timeouts = new Map() interface InitParams { params: { status: boolean message: string data: LX.UserApi.UserApiInfo } } interface ResponseParams { params: { status: boolean message: string data: { requestKey: string result: any } } } interface UpdateInfoParams { params: { data: { log: string updateUrl: string } } } export const init = () => { const handleInit = ({ params: { status, message, data: apiInfo } }: InitParams) => { // console.log('inited') // if (!status) { // console.log('init failed:', message) // global.lx_event.userApi.status(status = { status: true, apiInfo: { ...userApi, sources: apiInfo.sources } }) // return // } apiStatus = status ? { status: true, apiInfo: { ...userApi, sources: apiInfo.sources } } : { status: false, apiInfo: userApi, message } sendStatusChange(apiStatus) } const handleResponse = ({ params: { status, data: { requestKey, result }, message } }: ResponseParams) => { const request = requestQueue.get(requestKey) if (!request) return requestQueue.delete(requestKey) clearRequestTimeout(requestKey) if (status) { request[0](result) } else { request[1](new Error(message)) } } const handleOpenDevTools = () => { openDevTools() } const handleShowUpdateAlert = ({ params: { data } }: UpdateInfoParams) => { if (!userApi.allowShowUpdateAlert) return sendShowUpdateAlert({ name: userApi.name, description: userApi.description, log: data.log, updateUrl: data.updateUrl, }) } const handleGetProxy = () => { sendEvent(USER_API_RENDERER_EVENT_NAME.proxyUpdate, getProxy()) } mainOn(USER_API_RENDERER_EVENT_NAME.init, handleInit) mainOn(USER_API_RENDERER_EVENT_NAME.response, handleResponse) mainOn(USER_API_RENDERER_EVENT_NAME.openDevTools, handleOpenDevTools) mainOn(USER_API_RENDERER_EVENT_NAME.showUpdateAlert, handleShowUpdateAlert) mainOn(USER_API_RENDERER_EVENT_NAME.getProxy, handleGetProxy) } export const clearRequestTimeout = (requestKey: string) => { const timeout = timeouts.get(requestKey) if (timeout) { clearTimeout(timeout) timeouts.delete(requestKey) } } export const loadApi = async(apiId: string) => { if (!apiId) { apiStatus = { status: false, message: 'api id is null' } sendStatusChange(apiStatus) return } const targetApi = getUserApis().find(api => api.id == apiId) if (!targetApi) throw new Error('api not found') userApi = targetApi console.log('load api', userApi.name) await createWindow(userApi) // if (!userApi) return global.lx_event.userApi.status(status = { status: false, message: 'api script is not found' }) // if (!global.modules.userApiWindow) { // global.lx_event.userApi.status(status = { status: false, message: 'user api runtime is not defined' }) // throw new Error('user api window is not defined') // } // // const path = require('path') // // // eslint-disable-next-line no-undef // // userApi.script = require('fs').readFileSync(join(process.env.NODE_ENV !== 'production' ? __userApi : __dirname, 'renderer/test-api.js')).toString() // console.log('load api', userApi.name) // mainSend(global.modules.userApiWindow, USER_API_RENDERER_EVENT_NAME.init, { userApi }) } export const cancelRequest = (requestKey: string) => { if (!requestQueue.has(requestKey)) return const request = requestQueue.get(requestKey) request[1](new Error('Cancel request')) requestQueue.delete(requestKey) clearRequestTimeout(requestKey) } export const request = async({ requestKey, data }: LX.UserApi.UserApiRequestParams): Promise => await new Promise((resolve, reject) => { if (!userApi) { reject(new Error('user api is not load')) } // const requestKey = `request__${Math.random().toString().substring(2)}` const timeout = timeouts.get(requestKey) if (timeout) { clearTimeout(timeout) timeouts.delete(requestKey) cancelRequest(requestKey) } timeouts.set(requestKey, setTimeout(() => { cancelRequest(requestKey) }, 20000)) requestQueue.set(requestKey, [resolve, reject, data]) sendRequest({ requestKey, data }) }) export const getStatus = (): LX.UserApi.UserApiStatus => apiStatus export const setAllowShowUpdateAlert = (id: string, enable: boolean) => { if (!userApi || userApi.id != id) return userApi.allowShowUpdateAlert = enable } export const sendRequest = (reqData: { requestKey: string, data: any }) => { sendEvent(USER_API_RENDERER_EVENT_NAME.request, reqData) } ================================================ FILE: src/main/modules/userApi/utils.ts ================================================ import { userApis as defaultUserApis } from './config' import { STORE_NAMES } from '@common/constants' import getStore from '@main/utils/store' import zlib from 'node:zlib' let userApis: LX.UserApi.UserApiInfo[] | null let scripts = new Map() const saveData = () => { getStore(STORE_NAMES.USER_API).set('userApis', userApis!.map(api => { return { ...api, script: scripts.get(api.id), } })) } export const getUserApis = (): LX.UserApi.UserApiInfo[] => { if (userApis) return userApis const electronStore_userApi = getStore(STORE_NAMES.USER_API) let infoFull = electronStore_userApi.get('userApis') as LX.UserApi.UserApiInfoFull[] let requiredUpdate = false if (infoFull) { for (let i = 0; i < infoFull.length; i++) { const api = infoFull[i] if (api.version != null) continue requiredUpdate ||= true try { infoFull.splice(i, 1, { ...parseScriptInfo(api.script), ...api, }) } catch (e) { infoFull.splice(i, 1) i-- } } } else { infoFull = defaultUserApis electronStore_userApi.set('userApis', userApis) } userApis = infoFull.map(api => { if (api.allowShowUpdateAlert == null) api.allowShowUpdateAlert = false const { script, ...info } = api scripts.set(api.id, script) return info }) if (requiredUpdate) saveData() return userApis } const INFO_NAMES = { name: 24, description: 36, author: 56, homepage: 1024, version: 36, } as const type INFO_NAMES_Type = typeof INFO_NAMES const matchInfo = (scriptInfo: string) => { const infoArr = scriptInfo.split(/\r?\n/) const rxp = /^\s?\*\s?@(\w+)\s(.+)$/ const infos: Partial> = {} for (const info of infoArr) { const result = rxp.exec(info) if (!result) continue const key = result[1] as keyof typeof INFO_NAMES if (INFO_NAMES[key] == null) continue infos[key] = result[2].trim() } for (const [key, len] of Object.entries(INFO_NAMES) as Array<{ [K in keyof INFO_NAMES_Type]: [K, INFO_NAMES_Type[K]] }[keyof INFO_NAMES_Type]>) { infos[key] ||= '' if (infos[key] == null) infos[key] = '' else if (infos[key].length > len) infos[key] = infos[key].substring(0, len) + '...' } return infos as Record } const parseScriptInfo = (script: string) => { const result = /^\/\*[\S|\s]+?\*\//.exec(script) if (!result) throw new Error('无效的自定义源文件') let scriptInfo = matchInfo(result[0]) scriptInfo.name ||= `user_api_${new Date().toLocaleString()}` return scriptInfo } const deflateScript = async(script: string) => new Promise((resolve, reject) => { zlib.deflate(Buffer.from(script, 'utf8'), (err, buf) => { if (err) { reject(err) return } resolve('gz_' + buf.toString('base64')) }) }) const inflateScript = async(script: string) => new Promise((resolve, reject) => { if (script.startsWith('gz_')) { zlib.inflate(Buffer.from(script.substring(3), 'base64'), (err, buf) => { if (err) { reject(err) return } resolve(buf.toString('utf8')) }) } else resolve(script) }) export const importApi = async(scriptRaw: string): Promise => { let scriptInfo = parseScriptInfo(scriptRaw) const apiInfo = { id: `user_api_${Math.random().toString().substring(2, 5)}_${Date.now()}`, ...scriptInfo, allowShowUpdateAlert: true, } userApis ??= [] userApis.push(apiInfo) const script = await deflateScript(scriptRaw) scripts.set(apiInfo.id, script) saveData() return apiInfo } export const removeApi = (ids: string[]) => { if (!userApis) return for (let index = userApis.length - 1; index > -1; index--) { if (ids.includes(userApis[index].id)) { scripts.delete(userApis[index].id) userApis.splice(index, 1) ids.splice(index, 1) } } saveData() } export const setAllowShowUpdateAlert = (id: string, enable: boolean) => { const targetApi = userApis?.find(api => api.id == id) if (!targetApi) return targetApi.allowShowUpdateAlert = enable saveData() } export const getScript = async(id: string) => { return inflateScript(scripts.get(id) ?? '') } ================================================ FILE: src/main/modules/winLyric/config.ts ================================================ import { isLinux } from '@common/utils' import { closeWindow, createWindow, getBounds, isExistWindow, alwaysOnTopTools, setBounds, setIgnoreMouseEvents, setSkipTaskbar } from './main' import { sendConfigChange } from './rendererEvent' import { buildLyricConfig, getLyricWindowBounds, initWindowSize, watchConfigKeys } from './utils' let isLock: boolean let isEnable: boolean let isAlwaysOnTop: boolean let isAlwaysOnTopLoop: boolean let isShowTaskbar: boolean let isLockScreen: boolean let isHoverHide: boolean export const setLrcConfig = (keys: Array, setting: Partial) => { if (!watchConfigKeys.some(key => keys.includes(key))) return if (isExistWindow()) { sendConfigChange(buildLyricConfig(setting)) if (keys.includes('desktopLyric.isLock') && isLock != global.lx.appSetting['desktopLyric.isLock']) { isLock = global.lx.appSetting['desktopLyric.isLock'] if (global.lx.appSetting['desktopLyric.isLock']) { setIgnoreMouseEvents(true, { forward: !isLinux && global.lx.appSetting['desktopLyric.isHoverHide'] }) } else { setIgnoreMouseEvents(false, { forward: !isLinux && global.lx.appSetting['desktopLyric.isHoverHide'] }) } } if (keys.includes('desktopLyric.isHoverHide') && isHoverHide != global.lx.appSetting['desktopLyric.isHoverHide']) { isHoverHide = global.lx.appSetting['desktopLyric.isHoverHide'] if (!isLinux) { setIgnoreMouseEvents(global.lx.appSetting['desktopLyric.isLock'], { forward: global.lx.appSetting['desktopLyric.isHoverHide'] }) } } if (keys.includes('desktopLyric.isAlwaysOnTop') && isAlwaysOnTop != global.lx.appSetting['desktopLyric.isAlwaysOnTop']) { isAlwaysOnTop = global.lx.appSetting['desktopLyric.isAlwaysOnTop'] alwaysOnTopTools.setAlwaysOnTop(global.lx.appSetting['desktopLyric.isAlwaysOnTopLoop']) if (isAlwaysOnTop && global.lx.appSetting['desktopLyric.isAlwaysOnTopLoop']) { alwaysOnTopTools.startLoop() } else alwaysOnTopTools.clearLoop() } if (keys.includes('desktopLyric.isShowTaskbar') && isShowTaskbar != global.lx.appSetting['desktopLyric.isShowTaskbar']) { isShowTaskbar = global.lx.appSetting['desktopLyric.isShowTaskbar'] setSkipTaskbar(!global.lx.appSetting['desktopLyric.isShowTaskbar']) } if (keys.includes('desktopLyric.isAlwaysOnTopLoop') && isAlwaysOnTopLoop != global.lx.appSetting['desktopLyric.isAlwaysOnTopLoop']) { isAlwaysOnTopLoop = global.lx.appSetting['desktopLyric.isAlwaysOnTopLoop'] if (!global.lx.appSetting['desktopLyric.isAlwaysOnTop']) return if (isAlwaysOnTopLoop) { alwaysOnTopTools.startLoop() } else { alwaysOnTopTools.clearLoop() } } if (keys.includes('desktopLyric.isLockScreen') && isLockScreen != global.lx.appSetting['desktopLyric.isLockScreen']) { isLockScreen = global.lx.appSetting['desktopLyric.isLockScreen'] if (global.lx.appSetting['desktopLyric.isLockScreen']) { setBounds(getLyricWindowBounds(getBounds(), { x: 0, y: 0, w: global.lx.appSetting['desktopLyric.width'], h: global.lx.appSetting['desktopLyric.height'], })) } } if (keys.includes('desktopLyric.x') && setting['desktopLyric.x'] == null) { setBounds(initWindowSize( global.lx.appSetting['desktopLyric.x'], global.lx.appSetting['desktopLyric.y'], global.lx.appSetting['desktopLyric.width'], global.lx.appSetting['desktopLyric.height'], )) } } if (keys.includes('desktopLyric.enable') && isEnable != global.lx.appSetting['desktopLyric.enable']) { isEnable = global.lx.appSetting['desktopLyric.enable'] if (global.lx.appSetting['desktopLyric.enable']) { createWindow() } else { alwaysOnTopTools.clearLoop() closeWindow() } } } ================================================ FILE: src/main/modules/winLyric/index.ts ================================================ import { APP_EVENT_NAMES } from '@common/constants' import initRendererEvent, { sendMainWindowInitedEvent } from './rendererEvent' import { setLrcConfig } from './config' import { HOTKEY_DESKTOP_LYRIC } from '@common/hotKey' import { closeWindow, createWindow, isExistWindow } from './main' // import main from './main' // import { Event, EVENT_NAMES } from './event' let isMainWidnowFullscreen = false export default () => { initRendererEvent() // global.lx.event_app.winLyric = new Event() // global.app_event.winMain. global.lx.event_app.on('main_window_inited', () => { isMainWidnowFullscreen = global.lx.appSetting['common.startInFullscreen'] if (global.lx.appSetting['desktopLyric.enable']) { if (global.lx.appSetting['desktopLyric.fullscreenHide'] && isMainWidnowFullscreen) { closeWindow() } else { if (isExistWindow()) sendMainWindowInitedEvent() else createWindow() } } }) global.lx.event_app.on('updated_config', (keys, setting) => { setLrcConfig(keys, setting) if (keys.includes('desktopLyric.fullscreenHide') && global.lx.appSetting['desktopLyric.enable'] && isMainWidnowFullscreen) { if (global.lx.appSetting['desktopLyric.fullscreenHide']) closeWindow() else if (!isExistWindow()) createWindow() } }) global.lx.event_app.on('main_window_close', () => { closeWindow() }) global.lx.event_app.on('main_window_fullscreen', (isFullscreen) => { isMainWidnowFullscreen = isFullscreen if (global.lx.appSetting['desktopLyric.enable'] && global.lx.appSetting['desktopLyric.fullscreenHide']) { if (isFullscreen) closeWindow() else if (!isExistWindow()) createWindow() } }) // global.lx_event.mainWindow.on(MAIN_WINDOW_EVENT_NAME.setLyricInfo, info => { // if (!global.modules.lyricWindow) return // mainSend(global.modules.lyricWindow, ipcWinLyricNames.set_lyric_info, info) // }) global.lx.event_app.on('hot_key_down', ({ type, key }) => { let info = global.lx.hotKey.config.global.keys[key] if (!info || info.type != APP_EVENT_NAMES.winLyricName) return let newSetting: Partial = {} let settingKey: keyof LX.AppSetting switch (info.action) { case HOTKEY_DESKTOP_LYRIC.toggle_visible.action: settingKey = 'desktopLyric.enable' break case HOTKEY_DESKTOP_LYRIC.toggle_lock.action: settingKey = 'desktopLyric.isLock' break case HOTKEY_DESKTOP_LYRIC.toggle_always_top.action: settingKey = 'desktopLyric.isAlwaysOnTop' break default: return } newSetting[settingKey] = !global.lx.appSetting[settingKey] global.lx.event_app.update_config(newSetting) }) } export * from './main' export * from './rendererEvent' // export { // EVENT_NAMES, // } ================================================ FILE: src/main/modules/winLyric/main.ts ================================================ import path from 'node:path' import { BrowserWindow } from 'electron' import { debounce, getPlatform, isLinux, isWin } from '@common/utils' import { initWindowSize, minHeight, minWidth } from './utils' import { mainSend } from '@common/mainIpc' import { encodePath } from '@common/utils/electron' // require('./event') // require('./rendererEvent') let browserWindow: Electron.BrowserWindow | null = null let isWinBoundsUpdateing = false const saveBoundsConfig = debounce((config: Partial) => { global.lx.event_app.update_config(config) if (isWinBoundsUpdateing) isWinBoundsUpdateing = false }, 500) const winEvent = () => { if (!browserWindow) return // browserWindow.on('close', () => { // if (global.lx.appSetting['desktopLyric.enable'] && !global.lx.mainWindowClosed) { // browserWindow = null // global.lx.event_app.update_config({ 'desktopLyric.enable': false }) // } // }) browserWindow.on('closed', () => { browserWindow = null }) browserWindow.on('move', () => { // bounds = browserWindow.getBounds() // console.log('move', isWinBoundsUpdateing) if (isWinBoundsUpdateing) { const bounds = browserWindow!.getBounds() saveBoundsConfig({ 'desktopLyric.x': bounds.x, 'desktopLyric.y': bounds.y, 'desktopLyric.width': bounds.width, 'desktopLyric.height': bounds.height, }) } else if (isWin) { // Linux 不允许将窗口设置出屏幕之外,MacOS未知,故只在Windows下执行强制设置 // 非主动调整窗口触发的窗口位置变化将重置回设置值 browserWindow!.setBounds({ x: global.lx.appSetting['desktopLyric.x'] ?? 0, y: global.lx.appSetting['desktopLyric.y'] ?? 0, width: global.lx.appSetting['desktopLyric.width'], height: global.lx.appSetting['desktopLyric.height'], }) } }) browserWindow.on('resize', () => { // bounds = browserWindow.getBounds() // console.log(bounds) isWinBoundsUpdateing = true const bounds = browserWindow!.getBounds() saveBoundsConfig({ 'desktopLyric.x': bounds.x, 'desktopLyric.y': bounds.y, 'desktopLyric.width': bounds.width, 'desktopLyric.height': bounds.height, }) }) // browserWindow.on('restore', () => { // browserWindow.webContents.send('restore') // }) // browserWindow.on('focus', () => { // browserWindow.webContents.send('focus') // }) browserWindow.once('ready-to-show', () => { showWindow() if (global.lx.appSetting['desktopLyric.isLock']) { browserWindow!.setIgnoreMouseEvents(true, { forward: !isLinux && global.lx.appSetting['desktopLyric.isHoverHide'] }) } // linux下每次重开时貌似要重新设置置顶 // if (isLinux && global.lx.appSetting['desktopLyric.isAlwaysOnTop']) { // browserWindow!.setAlwaysOnTop(global.lx.appSetting['desktopLyric.isAlwaysOnTop'], 'screen-saver') // } if (global.lx.appSetting['desktopLyric.isAlwaysOnTop'] && global.lx.appSetting['desktopLyric.isAlwaysOnTopLoop']) alwaysOnTopTools.startLoop() browserWindow!.blur() }) } export const createWindow = () => { closeWindow() if (!global.envParams.workAreaSize) return let x = global.lx.appSetting['desktopLyric.x'] let y = global.lx.appSetting['desktopLyric.y'] let width = global.lx.appSetting['desktopLyric.width'] let height = global.lx.appSetting['desktopLyric.height'] let isAlwaysOnTop = global.lx.appSetting['desktopLyric.isAlwaysOnTop'] // let isLockScreen = global.lx.appSetting['desktopLyric.isLockScreen'] let isShowTaskbar = global.lx.appSetting['desktopLyric.isShowTaskbar'] // let { width: screenWidth, height: screenHeight } = global.envParams.workAreaSize const winSize = initWindowSize(x, y, width, height) global.lx.event_app.update_config({ 'desktopLyric.x': winSize.x, 'desktopLyric.y': winSize.y, 'desktopLyric.width': winSize.width, 'desktopLyric.height': winSize.height, }) const { shouldUseDarkColors, theme } = global.lx.theme /** * Initial window options */ browserWindow = new BrowserWindow({ height: winSize.height, width: winSize.width, x: winSize.x, y: winSize.y, minWidth, minHeight, useContentSize: true, frame: false, transparent: true, hasShadow: false, // enableRemoteModule: false, // icon: join(global.__static, isWin ? 'icons/256x256.ico' : 'icons/512x512.png'), resizable: isWin, minimizable: false, maximizable: false, fullscreenable: false, roundedCorners: false, show: false, alwaysOnTop: isAlwaysOnTop, skipTaskbar: !isShowTaskbar, webPreferences: { contextIsolation: false, webSecurity: false, sandbox: false, nodeIntegration: true, enableWebSQL: false, webgl: false, spellcheck: false, // 禁用拼写检查器 backgroundThrottling: false, }, }) const winURL = process.env.NODE_ENV !== 'production' ? 'http://localhost:9081/lyric.html' : `file://${path.join(encodePath(__dirname), 'lyric.html')}` void browserWindow.loadURL(winURL + `?os=${getPlatform()}&dark=${shouldUseDarkColors}&theme=${encodeURIComponent(JSON.stringify(theme))}`) winEvent() // browserWindow.webContents.openDevTools() global.lx.event_app.desktop_lyric_window_created(browserWindow) } export const isExistWindow = (): boolean => !!browserWindow export const closeWindow = () => { if (!browserWindow) return browserWindow.close() } export const showWindow = () => { if (!browserWindow) return browserWindow.show() } export const setResizeable = (isResizeable: boolean) => { if (!browserWindow) return browserWindow.setResizable(isResizeable) } export const sendEvent = (name: string, params?: T) => { if (!browserWindow) return mainSend(browserWindow, name, params) } export const getBounds = (): Electron.Rectangle => { if (!browserWindow) throw new Error('window is not available') return browserWindow.getBounds() } export const setBounds = (bounds: Electron.Rectangle) => { if (!browserWindow) return isWinBoundsUpdateing = true browserWindow.setBounds(bounds) } export const setIgnoreMouseEvents = (ignore: boolean, options?: Electron.IgnoreMouseEventsOptions) => { if (!browserWindow) return browserWindow.setIgnoreMouseEvents(ignore, options) } export const setSkipTaskbar = (skip: boolean) => { if (!browserWindow) return browserWindow.setSkipTaskbar(skip) } export const setAlwaysOnTop = (flag: boolean, level?: 'normal' | 'floating' | 'torn-off-menu' | 'modal-panel' | 'main-menu' | 'status' | 'pop-up-menu' | 'screen-saver' | undefined, relativeLevel?: number | undefined) => { if (!browserWindow) return browserWindow.setAlwaysOnTop(flag, level, relativeLevel) } export const getMainFrame = (): Electron.WebFrameMain | null => { if (!browserWindow) return null return browserWindow.webContents.mainFrame } interface AlwaysOnTopTools { timeout: NodeJS.Timeout | null setAlwaysOnTop: (isLoop: boolean) => void startLoop: () => void clearLoop: () => void } export const alwaysOnTopTools: AlwaysOnTopTools = { timeout: null, setAlwaysOnTop(isLoop) { this.clearLoop() setAlwaysOnTop(global.lx.appSetting['desktopLyric.isAlwaysOnTop'], 'screen-saver') // console.log(isLoop) if (isLoop) this.startLoop() }, startLoop() { this.clearLoop() this.timeout = setInterval(() => { if (!isExistWindow()) { this.clearLoop() return } setAlwaysOnTop(true, 'screen-saver') }, 500) }, clearLoop() { if (!this.timeout) return clearInterval(this.timeout) this.timeout = null }, } ================================================ FILE: src/main/modules/winLyric/rendererEvent.ts ================================================ import { registerRendererEvents as common } from '@main/modules/commonRenderers/common' import { mainOn, mainHandle } from '@common/mainIpc' import { WIN_LYRIC_RENDERER_EVENT_NAME } from '@common/ipcNames' import { buildLyricConfig, getLyricWindowBounds } from './utils' import { sendNewDesktopLyricClient } from '@main/modules/winMain' import { getBounds, getMainFrame, sendEvent, setBounds, setResizeable } from './main' import { MessageChannelMain } from 'electron' export default () => { // mainOn(WIN_LYRIC_RENDERER_EVENT_NAME.get_lyric_info, ({ params: action }) => { // sendMainEvent(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_info, { // name: WIN_LYRIC_RENDERER_EVENT_NAME.set_lyric_info, // modal: 'lyricWindow', // action, // }) // }) common(sendEvent) mainHandle>(WIN_LYRIC_RENDERER_EVENT_NAME.set_config, async({ params: config }) => { global.lx.event_app.update_config(config) }) mainHandle(WIN_LYRIC_RENDERER_EVENT_NAME.get_config, async() => { return buildLyricConfig(global.lx.appSetting) as LX.DesktopLyric.Config }) mainOn(WIN_LYRIC_RENDERER_EVENT_NAME.set_win_bounds, ({ params: options }) => { setBounds(getLyricWindowBounds(getBounds(), options)) }) mainOn(WIN_LYRIC_RENDERER_EVENT_NAME.set_win_resizeable, ({ params: resizable }) => { setResizeable(resizable) }) mainOn(WIN_LYRIC_RENDERER_EVENT_NAME.request_main_window_channel, ({ event }) => { if (event.senderFrame !== getMainFrame()) return // Create a new channel ... const { port1, port2 } = new MessageChannelMain() // ... send one end to the worker ... sendNewDesktopLyricClient(port1) // ... and the other end to the main window. event.senderFrame?.postMessage(WIN_LYRIC_RENDERER_EVENT_NAME.provide_main_window_channel, null, [port2]) // Now the main window and the worker can communicate with each other // without going through the main process! console.log('request_main_window_channel') }) } export const sendConfigChange = (setting: Partial) => { sendEvent(WIN_LYRIC_RENDERER_EVENT_NAME.on_config_change, setting) } export const sendMainWindowInitedEvent = () => { sendEvent(WIN_LYRIC_RENDERER_EVENT_NAME.main_window_inited) } ================================================ FILE: src/main/modules/winLyric/utils.ts ================================================ // 设置窗口位置、大小 export let minWidth = 38 export let minHeight = 38 // const updateBounds = (bounds: Bounds) => { // bounds.x = bounds.x // return bounds // } /** * * @param bounds 当前设置 * @param param 新设置(相对于当前设置) * @returns */ export const getLyricWindowBounds = (bounds: Electron.Rectangle, { x, y, w, h }: LX.DesktopLyric.NewBounds): Electron.Rectangle => { if (w < minWidth) w = minWidth if (h < minHeight) h = minHeight if (global.lx.appSetting['desktopLyric.isLockScreen']) { if (!global.envParams.workAreaSize) return bounds const maxWinW = global.envParams.workAreaSize.width const maxWinH = global.envParams.workAreaSize.height if (w > maxWinW) w = maxWinW if (h > maxWinH) h = maxWinH const maxX = global.envParams.workAreaSize.width - w const maxY = global.envParams.workAreaSize.height - h x += bounds.x y += bounds.y if (x > maxX) x = maxX else if (x < 0) x = 0 if (y > maxY) y = maxY else if (y < 0) y = 0 } else { y += bounds.y x += bounds.x } // console.log('util bounds', bounds) return { width: w, height: h, x, y } } export const watchConfigKeys = [ 'desktopLyric.enable', 'desktopLyric.isLock', 'desktopLyric.isAlwaysOnTop', 'desktopLyric.isAlwaysOnTopLoop', 'desktopLyric.isShowTaskbar', 'desktopLyric.pauseHide', 'desktopLyric.audioVisualization', 'desktopLyric.width', 'desktopLyric.height', 'desktopLyric.x', 'desktopLyric.y', 'desktopLyric.isLockScreen', 'desktopLyric.isDelayScroll', 'desktopLyric.scrollAlign', 'desktopLyric.isHoverHide', 'desktopLyric.direction', 'desktopLyric.style.align', 'desktopLyric.style.lyricUnplayColor', 'desktopLyric.style.lyricPlayedColor', 'desktopLyric.style.lyricShadowColor', 'desktopLyric.style.font', 'desktopLyric.style.fontSize', 'desktopLyric.style.lineGap', // 'desktopLyric.style.fontWeight', 'desktopLyric.style.opacity', 'desktopLyric.style.ellipsis', 'desktopLyric.style.isFontWeightFont', 'desktopLyric.style.isFontWeightLine', 'desktopLyric.style.isFontWeightExtended', 'desktopLyric.style.isZoomActiveLrc', 'common.langId', 'player.isShowLyricTranslation', 'player.isShowLyricRoma', 'player.isSwapLyricTranslationAndRoma', 'player.isPlayLxlrc', 'player.playbackRate', ] satisfies Array export const buildLyricConfig = (appSetting: Partial): Partial => { const setting: Partial = {} for (const key of watchConfigKeys) { // @ts-expect-error if (key in appSetting) setting[key] = appSetting[key] } return setting } export const initWindowSize = (x: LX.AppSetting['desktopLyric.x'], y: LX.AppSetting['desktopLyric.y'], width: LX.AppSetting['desktopLyric.width'], height: LX.AppSetting['desktopLyric.height']) => { if (x == null || y == null) { if (width < minWidth) width = minWidth if (height < minHeight) height = minHeight if (global.envParams.workAreaSize) { x = global.envParams.workAreaSize.width - width y = global.envParams.workAreaSize.height - height } else { x = y = 0 } } else { let bounds = getLyricWindowBounds({ x, y, width, height }, { x: 0, y: 0, w: width, h: height }) x = bounds.x y = bounds.y width = bounds.width height = bounds.height } return { x, y, width, height, } } ================================================ FILE: src/main/modules/winMain/autoUpdate.ts ================================================ import { autoUpdater } from 'electron-updater' import { log, isWin } from '@common/utils' import { mainOn } from '@common/mainIpc' import { isExistWindow, sendEvent } from './index' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' autoUpdater.logger = log autoUpdater.autoDownload = false // autoUpdater.forceDevUpdateConfig = true // autoUpdater.autoDownload = false // let isFirstCheckedUpdate = true log.info('App starting...') // ------------------------------------------------------------------- // Open a window that displays the version // // THIS SECTION IS NOT REQUIRED // // This isn't required for auto-updates to work, but it's easier // for the app to show a window than to have to click "About" to see // that updates are working. // ------------------------------------------------------------------- // let win function sendStatusToWindow(text: string) { log.info(text) // ipcMain.send('message', text) } // ------------------------------------------------------------------- // Auto updates // // For details about these events, see the Wiki: // https://github.com/electron-userland/electron-builder/wiki/Auto-Update#events // // The app doesn't need to listen to any events except `update-downloaded` // // Uncomment any of the below events to listen for them. Also, // look in the previous section to see them being used. // ------------------------------------------------------------------- // autoUpdater.on('checking-for-update', () => { // }) // autoUpdater.on('update-available', (ev, info) => { // }) // autoUpdater.on('update-not-available', (ev, info) => { // }) // autoUpdater.on('error', (ev, err) => { // }) // autoUpdater.on('download-progress', (ev, progressObj) => { // }) // autoUpdater.on('update-downloaded', (ev, info) => { // // Wait 5 seconds, then quit and install // // In your application, you don't need to wait 5 seconds. // // You could call autoUpdater.quitAndInstall(); immediately // // setTimeout(function() { // // autoUpdater.quitAndInstall() // // }, 5000) // }) interface WaitEvent { type: string info: any } // let waitEvent: WaitEvent[] = [] const handleSendEvent = (action: WaitEvent) => { if (isExistWindow()) { setTimeout(() => { // 延迟发送事件,过早发送可能渲染进程还没启动完成 sendEvent(action.type, action.info) }, 1000) } } export default () => { autoUpdater.on('checking-for-update', () => { sendStatusToWindow('Checking for update...') }) autoUpdater.on('update-available', info => { sendStatusToWindow('Update available.') handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_available, info }) }) autoUpdater.on('update-not-available', info => { sendStatusToWindow('Update not available.') handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_not_available, info }) }) autoUpdater.on('error', err => { sendStatusToWindow('Error in auto-updater.') handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_error, info: err.message }) }) autoUpdater.on('download-progress', progressObj => { let log_message = `Download speed: ${progressObj.bytesPerSecond}` log_message = `${log_message} - Downloaded ${progressObj.percent}%` log_message = `${log_message} (progressObj.transferred/${progressObj.total})` sendStatusToWindow(log_message) handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_progress, info: progressObj }) }) autoUpdater.on('update-downloaded', info => { sendStatusToWindow('Update downloaded.') handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_downloaded, info }) }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.update_check, () => { console.log('check') checkUpdate() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.update_download_update, () => { if (!autoUpdater.isUpdaterActive()) return void autoUpdater.downloadUpdate() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.quit_update, () => { global.lx.isSkipTrayQuit = true setTimeout(() => { autoUpdater.quitAndInstall(true, true) }, 1000) }) } const checkUpdate = () => { // if (!isFirstCheckedUpdate) { // if (waitEvent.length) { // waitEvent.forEach((event, index) => { // setTimeout(() => { // 延迟发送事件,过早发送可能渲染进程还没启动完成 // sendEvent(event.type, event.info) // }, 2000 * (index + 1)) // }) // waitEvent = [] // } // return // } // isFirstCheckedUpdate = false // 由于集合安装包中不包含win arm版,这将会导致arm版更新失败 if (isWin && process.arch.includes('arm')) { handleSendEvent({ type: WIN_MAIN_RENDERER_EVENT_NAME.update_error, info: 'failed' }) } else { autoUpdater.autoDownload = global.lx.appSetting['common.tryAutoUpdate'] void autoUpdater.checkForUpdates() } } ================================================ FILE: src/main/modules/winMain/index.ts ================================================ import initRendererEvent, { handleKeyDown, hotKeyConfigUpdate } from './rendererEvent' import { APP_EVENT_NAMES } from '@common/constants' import { createWindow, minimize, setProgressBar, setProxy, setThumbarButtons, toggleHide, toggleMinimize } from './main' import initUpdate from './autoUpdate' import { HOTKEY_COMMON } from '@common/hotKey' import { quitApp } from '@main/app' export default () => { initRendererEvent() initUpdate() global.lx.event_app.on('hot_key_down', ({ type, key }) => { let info = global.lx.hotKey.config.global.keys[key] if (info?.type != APP_EVENT_NAMES.winMainName) return switch (info.action) { case HOTKEY_COMMON.close.action: quitApp() break case HOTKEY_COMMON.hide_toggle.action: toggleHide() break case HOTKEY_COMMON.min.action: minimize() break case HOTKEY_COMMON.min_toggle.action: toggleMinimize() break default: handleKeyDown(type, key) break } }) global.lx.event_app.on('hot_key_config_update', (config) => { hotKeyConfigUpdate(config) }) global.lx.event_app.on('app_inited', () => { createWindow() }) const keys = (['status', 'collect'] as const) satisfies Array const taskBarButtonFlags: LX.TaskBarButtonFlags = { empty: true, collect: false, play: false, next: true, prev: true, } const progressStatus = { progress: -1, status: 'none' as Electron.ProgressBarOptions['mode'], } let showProgress = global.lx.appSetting['player.isShowTaskProgess'] global.lx.event_app.on('player_status', (status) => { if (status.status) { switch (status.status) { case 'paused': taskBarButtonFlags.play = false taskBarButtonFlags.empty &&= false progressStatus.status = 'paused' break case 'error': taskBarButtonFlags.play = false taskBarButtonFlags.empty &&= false progressStatus.status = 'error' break case 'playing': taskBarButtonFlags.play = true taskBarButtonFlags.empty &&= false progressStatus.status = 'normal' break case 'stoped': taskBarButtonFlags.play &&= false taskBarButtonFlags.empty = true progressStatus.status = 'none' progressStatus.progress = 0 break } if (showProgress) { setProgressBar(progressStatus.progress, { mode: progressStatus.status, }) } } if (keys.some(k => status[k] != null)) { if (status.collect != null) taskBarButtonFlags.collect = status.collect setThumbarButtons(taskBarButtonFlags) } if (showProgress && status.progress != null) { const progress = global.lx.player_status.duration ? status.progress / global.lx.player_status.duration : 0 if (progress.toFixed(2) != progressStatus.progress.toFixed(2)) { progressStatus.progress = progress < 0.01 ? 0.01 : progress setProgressBar(progressStatus.progress, { mode: progressStatus.status, }) } } }) global.lx.event_app.on('updated_config', (keys, setting) => { if (keys.includes('player.isShowTaskProgess')) { showProgress = setting['player.isShowTaskProgess']! if (showProgress) { setProgressBar(progressStatus.progress, { mode: progressStatus.status, }) } else { setProgressBar(-1, { mode: 'none' }) } } if (keys.includes('network.proxy.enable') || (global.lx.appSetting['network.proxy.enable'] && keys.some(k => k.includes('network.proxy.')))) { setProxy() } }) } export * from './main' export * from './rendererEvent' ================================================ FILE: src/main/modules/winMain/main.ts ================================================ import { BrowserWindow, dialog, session } from 'electron' import path from 'node:path' import { createTaskBarButtons, getWindowSizeInfo } from './utils' import { getPlatform, isLinux, isWin } from '@common/utils' import { getProxy, openDevTools as handleOpenDevTools } from '@main/utils' import { mainSend } from '@common/mainIpc' import { sendFocus, sendTaskbarButtonClick } from './rendererEvent' import { encodePath } from '@common/utils/electron' let browserWindow: Electron.BrowserWindow | null = null const winEvent = () => { if (!browserWindow) return browserWindow.on('close', event => { if (global.lx.isSkipTrayQuit || !global.lx.appSetting['tray.enable']) { browserWindow!.setProgressBar(-1) // global.lx.mainWindowClosed = true global.lx.event_app.main_window_close() return } event.preventDefault() browserWindow!.hide() }) browserWindow.on('closed', () => { // global.lx.mainWindowClosed = true browserWindow = null }) // browserWindow.on('restore', () => { // browserWindow.webContents.send('restore') // }) browserWindow.on('focus', () => { sendFocus() global.lx.event_app.main_window_focus() }) browserWindow.on('blur', () => { global.lx.event_app.main_window_blur() }) browserWindow.once('ready-to-show', () => { if (!global.envParams.cmdParams.hidden) { showWindow() setThumbarButtons() } global.lx.event_app.main_window_ready_to_show() }) browserWindow.on('show', () => { global.lx.event_app.main_window_show() // 修复隐藏窗口后再显示时任务栏按钮丢失的问题 setThumbarButtons() }) browserWindow.on('hide', () => { global.lx.event_app.main_window_hide() }) } export const createWindow = () => { closeWindow() const windowSizeInfo = getWindowSizeInfo(global.lx.appSetting['common.windowSizeId']) const { shouldUseDarkColors, theme } = global.lx.theme const ses = session.fromPartition('persist:win-main') const proxy = getProxy() setSesProxy(ses, proxy?.host, proxy?.port) /** * Initial window options */ const options: Electron.BrowserWindowConstructorOptions = { height: windowSizeInfo.height, useContentSize: true, width: windowSizeInfo.width, frame: false, transparent: !global.envParams.cmdParams.dt, hasShadow: global.envParams.cmdParams.dt, // enableRemoteModule: false, // icon: join(global.__static, isWin ? 'icons/256x256.ico' : 'icons/512x512.png'), resizable: false, maximizable: false, fullscreenable: true, roundedCorners: global.envParams.cmdParams.dt, show: false, webPreferences: { session: ses, nodeIntegrationInWorker: true, contextIsolation: false, webSecurity: false, nodeIntegration: true, sandbox: false, enableWebSQL: false, webgl: false, spellcheck: false, // 禁用拼写检查器 }, } if (global.envParams.cmdParams.dt) options.backgroundColor = theme.colors['--color-primary-light-1000'] if (global.lx.appSetting['common.startInFullscreen']) { options.fullscreen = true if (isLinux) options.resizable = true } browserWindow = new BrowserWindow(options) const winURL = process.env.NODE_ENV !== 'production' ? 'http://localhost:9080' : `file://${path.join(encodePath(__dirname), 'index.html')}` void browserWindow.loadURL(winURL + `?os=${getPlatform()}&dt=${global.envParams.cmdParams.dt}&dark=${shouldUseDarkColors}&theme=${encodeURIComponent(JSON.stringify(theme))}`) winEvent() if (global.envParams.cmdParams.odt) handleOpenDevTools(browserWindow.webContents) // global.lx.mainWindowClosed = false // browserWindow.webContents.openDevTools() global.lx.event_app.main_window_created(browserWindow) } export const isExistWindow = (): boolean => !!browserWindow export const isShowWindow = (): boolean => { if (!browserWindow) return false return browserWindow.isVisible() && (isWin ? true : browserWindow.isFocused()) } export const closeWindow = () => { if (!browserWindow) return browserWindow.close() } const setSesProxy = (ses: Electron.Session, host?: string, port?: string | number) => { if (host) { void ses.setProxy({ mode: 'fixed_servers', proxyRules: `http://${host}:${port}`, }) } else { void ses.setProxy({ mode: 'direct', }) } } export const setProxy = () => { if (!browserWindow) return const proxy = getProxy() setSesProxy(browserWindow.webContents.session, proxy?.host, proxy?.port) } export const sendEvent = (name: string, params?: T) => { if (!browserWindow) return mainSend(browserWindow, name, params) } export const showSelectDialog = async(options: Electron.OpenDialogOptions) => { if (!browserWindow) throw new Error('main window is undefined') return dialog.showOpenDialog(browserWindow, options) } export const showDialog = ({ type, message, detail }: Electron.MessageBoxSyncOptions) => { if (!browserWindow) return dialog.showMessageBoxSync(browserWindow, { type, message, detail, }) } export const showSaveDialog = async(options: Electron.SaveDialogOptions) => { if (!browserWindow) throw new Error('main window is undefined') return dialog.showSaveDialog(browserWindow, options) } export const minimize = () => { if (!browserWindow) return browserWindow.minimize() } export const maximize = () => { if (!browserWindow) return browserWindow.maximize() } export const unmaximize = () => { if (!browserWindow) return browserWindow.unmaximize() } export const toggleHide = () => { if (!browserWindow) return browserWindow.isVisible() ? browserWindow.hide() : browserWindow.show() } export const toggleMinimize = () => { if (!browserWindow) return if (browserWindow.isVisible()) { if (browserWindow.isMinimized()) browserWindow.restore() else browserWindow.minimize() } else browserWindow.show() } export const showWindow = () => { if (!browserWindow) return if (browserWindow.isVisible()) { if (browserWindow.isMinimized()) browserWindow.restore() else browserWindow.focus() } else browserWindow.show() } export const hideWindow = () => { if (!browserWindow) return browserWindow.hide() } export const setWindowBounds = (options: Partial) => { if (!browserWindow) return browserWindow.setBounds(options) } export const setProgressBar = (progress: number, options?: Electron.ProgressBarOptions) => { if (!browserWindow) return browserWindow.setProgressBar(progress, options) } export const setIgnoreMouseEvents = (ignore: boolean, options?: Electron.IgnoreMouseEventsOptions) => { if (!browserWindow) return browserWindow.setIgnoreMouseEvents(ignore, options) } export const toggleDevTools = () => { if (!browserWindow) return if (browserWindow.webContents.isDevToolsOpened()) { browserWindow.webContents.closeDevTools() } else { handleOpenDevTools(browserWindow.webContents) } } export const setFullScreen = (isFullscreen: boolean): boolean => { if (!browserWindow) return false if (isLinux) { // linux 需要先设置为可调整窗口大小才能全屏 if (isFullscreen) { browserWindow.setResizable(isFullscreen) browserWindow.setFullScreen(isFullscreen) } else { browserWindow.setFullScreen(isFullscreen) browserWindow.setResizable(isFullscreen) } } else { browserWindow.setFullScreen(isFullscreen) } return isFullscreen } const taskBarButtonFlags: LX.TaskBarButtonFlags = { empty: true, collect: false, play: false, next: true, prev: true, } export const setThumbarButtons = ({ empty, collect, play, next, prev }: LX.TaskBarButtonFlags = taskBarButtonFlags) => { if (!isWin || !browserWindow) return taskBarButtonFlags.empty = empty taskBarButtonFlags.collect = collect taskBarButtonFlags.play = play taskBarButtonFlags.next = next taskBarButtonFlags.prev = prev browserWindow.setThumbarButtons(createTaskBarButtons(taskBarButtonFlags, action => { sendTaskbarButtonClick(action) })) } export const setThumbnailClip = (region: Electron.Rectangle) => { if (!browserWindow) return browserWindow.setThumbnailClip(region) } export const clearCache = async() => { if (!browserWindow) throw new Error('main window is undefined') await browserWindow.webContents.session.clearCache() } export const getCacheSize = async() => { if (!browserWindow) throw new Error('main window is undefined') return browserWindow.webContents.session.getCacheSize() } export const getWebContents = (): Electron.WebContents => { if (!browserWindow) throw new Error('main window is undefined') return browserWindow.webContents } ================================================ FILE: src/main/modules/winMain/rendererEvent/app.ts ================================================ // const path = require('path') import { app } from 'electron' import { mainHandle, mainOn } from '@common/mainIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' // import { name as defaultName } from '../../../../../package.json' import { minimize, maximize, closeWindow, showWindow, setFullScreen, sendEvent, clearCache, getCacheSize, toggleDevTools, setWindowBounds, setIgnoreMouseEvents, // setThumbnailClip, toggleMinimize, toggleHide, showSelectDialog, showDialog, showSaveDialog, } from '@main/modules/winMain' import { quitApp } from '@main/app' import { getAllThemes, removeTheme, saveTheme, setPowerSaveBlocker } from '@main/utils' import { openDirInExplorer } from '@common/utils/electron' export default () => { // 设置应用名称 // mainOn(WIN_MAIN_RENDERER_EVENT_NAME.set_app_name, ({ params: name }) => { // if (name == null) { // app.setName(defaultName) // } else { // app.setName(name) // } // }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.quit, () => { quitApp() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.min_toggle, () => { toggleMinimize() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.hide_toggle, () => { toggleHide() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.min, () => { minimize() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.max, () => { maximize() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.focus, () => { showWindow() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.set_power_save_blocker, ({ params: enabled }) => { setPowerSaveBlocker(enabled) }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.close, ({ params: isForce }) => { if (isForce) { app.exit(0) return } closeWindow() }) // 全屏 mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.fullscreen, async({ params: isFullscreen }) => { global.lx.event_app.main_window_fullscreen(isFullscreen) return setFullScreen(isFullscreen) }) // 选择目录 mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.show_select_dialog, async({ params: options }) => { return showSelectDialog(options) }) // 显示弹窗信息 mainOn(WIN_MAIN_RENDERER_EVENT_NAME.show_dialog, ({ params }) => { showDialog(params) }) // 显示保存弹窗 mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.show_save_dialog, async({ params }) => { return showSaveDialog(params) }) // 在资源管理器中定位文件 mainOn(WIN_MAIN_RENDERER_EVENT_NAME.open_dir_in_explorer, async({ params }) => { return openDirInExplorer(params) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_cache, async() => { await clearCache() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_cache_size, async() => { return getCacheSize() }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.open_dev_tools, () => { toggleDevTools() }) mainOn>(WIN_MAIN_RENDERER_EVENT_NAME.set_window_size, ({ params }) => { setWindowBounds(params) }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.set_ignore_mouse_events, ({ params: isIgnored }) => { isIgnored ? setIgnoreMouseEvents(isIgnored, { forward: true }) : setIgnoreMouseEvents(false) }) // mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.taskbar_set_thumbnail_clip, async({ params }) => { // return setThumbnailClip(params) // }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.player_status, ({ params }) => { // setThumbarButtons(params) global.lx.event_app.player_status(params) }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.inited, () => { global.lx.event_app.main_window_inited() }) mainHandle<{ themes: LX.Theme[], userThemes: LX.Theme[] }>(WIN_MAIN_RENDERER_EVENT_NAME.get_themes, async() => { return getAllThemes() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.save_theme, async({ params: theme }) => { saveTheme(theme) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.remove_theme, async({ params: id }) => { removeTheme(id) }) } export const sendFocus = () => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.focus) } export const sendTaskbarButtonClick = (action: LX.Player.StatusButtonActions, data?: unknown) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.player_action_on_button_click, { action, data }) } export const sendConfigChange = (setting: Partial) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.on_config_change, setting) } ================================================ FILE: src/main/modules/winMain/rendererEvent/data.ts ================================================ import { STORE_NAMES } from '@common/constants' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainOn, mainHandle } from '@common/mainIpc' import getStore from '@main/utils/store' export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_data, ({ params: path }) => { return getStore(STORE_NAMES.DATA).get(path) as any }) mainOn<{ path: string data: any }>(WIN_MAIN_RENDERER_EVENT_NAME.save_data, ({ params: { path, data } }) => { getStore(STORE_NAMES.DATA).set(path, data) }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/download.ts ================================================ import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainHandle } from '@common/mainIpc' export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.download_list_get, async() => { return global.lx.worker.dbService.getDownloadList() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.download_list_add, async({ params: { list, addMusicLocationType } }) => { await global.lx.worker.dbService.downloadInfoSave(list, addMusicLocationType) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.download_list_update, async({ params: list }) => { await global.lx.worker.dbService.downloadInfoUpdate(list) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.download_list_remove, async({ params: ids }) => { await global.lx.worker.dbService.downloadInfoRemove(ids) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.download_list_clear, async() => { await global.lx.worker.dbService.downloadInfoClear() }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/hotKey.ts ================================================ import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainHandle } from '@common/mainIpc' import { sendEvent } from '../main' // import getStore from '@common/store' // const { registerHotkey, unRegisterHotkey } = require('../modules/hotKey/utils') // mainHandle(ipcMainWindowNames.set_hot_key_config, async(event, { action, data }) => { // switch (action) { // case 'config': // global.lx_event.hotKey.saveConfig(data.data, MAIN_WINDOW_EVENT_NAME.source) // return // case 'register': // return registerHotkey(data) // case 'unregister': // return unRegisterHotkey(data) // } // }) export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_hot_key, async() => { // const electronStore_hotKey = getStore('hotKey') return { local: global.lx.hotKey?.config.local, global: global.lx.hotKey?.config.global, } }) // global.lx.event_app.on(APP_EVENT_NAMES.hotKeyConfig, (config, source) => { // if (!global.modules.mainWindow || source === MAIN_WINDOW_EVENT_NAME.name) return // mainSend(global.modules.mainWindow, WIN_MAIN_RENDERER_EVENT_NAME.set_hot_key_config, { config, source }) // }) } export const handleKeyDown = (type: string, key: string) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.key_down, { type, key }) } export const hotKeyConfigUpdate = (config: LX.HotKeyConfigAll) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.set_hot_key_config, config) } ================================================ FILE: src/main/modules/winMain/rendererEvent/index.ts ================================================ import { registerRendererEvents as common } from '@main/modules/commonRenderers/common' import { registerRendererEvents as list } from '@main/modules/commonRenderers/list' import { registerRendererEvents as dislike } from '@main/modules/commonRenderers/dislike' import app, { sendConfigChange } from './app' import hotKey from './hotKey' import kw_decodeLyric from './kw_decodeLyric' import tx_decodeLyric from './tx_decodeLyric' import userApi from './userApi' import sync from './sync' import data from './data' import music from './music' import download from './download' import soundEffect from './soundEffect' import openAPI from './openAPI' import { sendEvent } from '../main' export * from './app' export * from './hotKey' export * from './userApi' export * from './sync' export * from './process' let isInitialized = false export default () => { if (isInitialized) return isInitialized = true common(sendEvent) list(sendEvent) dislike(sendEvent) app() hotKey() kw_decodeLyric() tx_decodeLyric() userApi() sync() data() music() download() soundEffect() openAPI() global.lx.event_app.on('updated_config', (keys, setting) => { sendConfigChange(setting) }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/kw_decodeLyric.ts ================================================ import { inflate } from 'zlib' import iconv from 'iconv-lite' import { mainHandle } from '@common/mainIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' const handleInflate = async(data: Buffer) => { return new Promise((resolve: (result: Buffer) => void, reject) => { inflate(data, (err, result) => { if (err) { reject(err) return } resolve(result) }) }) } const buf_key = Buffer.from('yeelion') const buf_key_len = buf_key.length const decodeLyric = async(buf: Buffer, isGetLyricx: boolean) => { // const info = buf.slice(0, index).toString() // if (!info.startsWith('tp=content')) return null // const isLyric = info.includes('\r\nlrcx=0\r\n') if (buf.toString('utf8', 0, 10) != 'tp=content') return '' // const index = buf.indexOf('\r\n\r\n') + 4 const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4)) if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030') const buf_str = Buffer.from(lrcData.toString(), 'base64') const buf_str_len = buf_str.length const output = new Uint8Array(buf_str_len) let i = 0 while (i < buf_str_len) { let j = 0 while (j < buf_key_len && i < buf_str_len) { output[i] = buf_str[i] ^ buf_key[j] i++ j++ } } return iconv.decode(Buffer.from(output), 'gb18030') } export default () => { mainHandle<{ lrcBase64: string, isGetLyricx: boolean }, string>(WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric, async({ params: { lrcBase64, isGetLyricx } }) => { const lrc = await decodeLyric(Buffer.from(lrcBase64, 'base64'), isGetLyricx) return Buffer.from(lrc).toString('base64') }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/music.ts ================================================ import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainHandle } from '@common/mainIpc' export default () => { // =========================歌词========================= mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_palyer_lyric, async({ params: id }) => { // return (getStore(LRC_EDITED, true, false).get(id) as LX.Music.LyricInfo | undefined) ?? // getStore(LRC_RAW, true, false).get(id, {}) as LX.Music.LyricInfo return global.lx.worker.dbService.getPlayerLyric(id) }) // 原始歌词 mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_raw, async({ params: id }) => { return global.lx.worker.dbService.getRawLyric(id) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_raw, async({ params: { id, lyrics } }) => { await global.lx.worker.dbService.rawLyricAdd(id, lyrics) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_lyric_raw, async() => { await global.lx.worker.dbService.rawLyricClear() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_raw_count, async() => { return global.lx.worker.dbService.rawLyricCount() }) // 已编辑的歌词 mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_edited, async({ params: id }) => { return global.lx.worker.dbService.getEditedLyric(id) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_edited, async({ params: { id, lyrics } }) => { await global.lx.worker.dbService.editedLyricUpdateAddAndUpdate(id, lyrics) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.remove_lyric_edited, async({ params: id }) => { await global.lx.worker.dbService.editedLyricRemove([id]) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_lyric_edited, async() => { await global.lx.worker.dbService.editedLyricClear() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_edited_count, async() => { return global.lx.worker.dbService.editedLyricCount() }) // =========================歌曲URL========================= mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_music_url, async({ params: id }) => { return (await global.lx.worker.dbService.getMusicUrl(id)) ?? '' }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.save_music_url, async({ params: { id, url } }) => { await global.lx.worker.dbService.musicUrlSave([{ id, url }]) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_music_url, async() => { await global.lx.worker.dbService.musicUrlClear() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_music_url_count, async() => { return global.lx.worker.dbService.musicUrlCount() }) // =========================换源歌曲========================= mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_other_source, async({ params: id }) => { return global.lx.worker.dbService.getMusicInfoOtherSource(id) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.save_other_source, async({ params: { id, list } }) => { await global.lx.worker.dbService.musicInfoOtherSourceAdd(id, list) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_other_source, async() => { await global.lx.worker.dbService.musicInfoOtherSourceClear() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_other_source_count, async() => { return global.lx.worker.dbService.musicInfoOtherSourceCount() }) // mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.remove_dislike_music_infos, async({ params: ids }) => { // await global.lx.worker.dbService.dislikeInfoRemove(ids) // }) // mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.clear_dislike_music_infos, async() => { // await global.lx.worker.dbService.dislikeInfoClear() // }) // =========================我的列表========================= // mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_playlist, async({ params: isIgnoredError = false }) => { // const electronStore_list = getStore('playList', isIgnoredError, false) // return { // defaultList: electronStore_list.get('defaultList'), // loveList: electronStore_list.get('loveList'), // tempList: electronStore_list.get('tempList'), // userList: electronStore_list.get('userList'), // downloadList: getStore('downloadList').get('list'), // } // }) // const handleSaveList = ({ defaultList, loveList, userList, tempList }: Partial) => { // let data: Partial = {} // if (defaultList != null) data.defaultList = defaultList // if (loveList != null) data.loveList = loveList // if (userList != null) data.userList = userList // if (tempList != null) data.tempList = tempList // getStore('playList').set(data) // } // mainOn(WIN_MAIN_RENDERER_EVENT_NAME.save_playlist, ({ params }) => { // switch (params.type) { // case 'myList': // handleSaveList(params.data) // global.lx.event_app.save_my_list(params.data) // break // case 'downloadList': // getStore('downloadList').set('list', params.data) // break // } // }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/openAPI.ts ================================================ import { mainHandle } from '@common/mainIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { startServer, stopServer, getStatus, } from '@main/modules/openApi' export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.open_api_action, async({ params: data }) => { switch (data.action) { case 'enable': return data.data.enable ? await startServer(parseInt(data.data.port), data.data.bindLan) : await stopServer() case 'status': return getStatus() } }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/process.ts ================================================ import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { getWebContents } from '../main' // export default () => { // } /** * 发送桌面歌词进程创建事件 * @param port 端口 */ export const sendNewDesktopLyricClient = (port: Electron.MessagePortMain) => { getWebContents().postMessage(WIN_MAIN_RENDERER_EVENT_NAME.process_new_desktop_lyric_client, null, [port]) } ================================================ FILE: src/main/modules/winMain/rendererEvent/soundEffect.ts ================================================ import { STORE_NAMES } from '@common/constants' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainOn, mainHandle } from '@common/mainIpc' import getStore from '@main/utils/store' export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_eq_preset, async() => { return getStore(STORE_NAMES.SOUND_EFFECT).get('eqPreset') as LX.SoundEffect.EQPreset[] | null ?? [] }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_eq_preset, ({ params }) => { getStore(STORE_NAMES.SOUND_EFFECT).set('eqPreset', params) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_convolution_preset, async() => { return getStore(STORE_NAMES.SOUND_EFFECT).get('convolutionPreset') as LX.SoundEffect.ConvolutionPreset[] | null ?? [] }) mainOn(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_convolution_preset, ({ params }) => { getStore(STORE_NAMES.SOUND_EFFECT).set('convolutionPreset', params) }) // mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_pitch_shifter_preset, async() => { // return getStore(STORE_NAMES.SOUND_EFFECT).get('pitchShifterPreset') as LX.SoundEffect.PitchShifterPreset[] | null ?? [] // }) // mainOn(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_pitch_shifter_preset, ({ params }) => { // getStore(STORE_NAMES.SOUND_EFFECT).set('pitchShifterPreset', params) // }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/sync.ts ================================================ import { mainHandle } from '@common/mainIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { startServer, stopServer, getServerStatus, generateCode, connectServer, disconnectServer, getClientStatus, getServerDevices, removeServerDevice, } from '@main/modules/sync' import { sendEvent } from '../main' let selectModeListenr: ((mode: LX.Sync.ModeTypes[keyof LX.Sync.ModeTypes] | null) => void) | null = null export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.sync_action, async({ params: data }) => { switch (data.action) { case 'enable_server': data.data.enable ? await startServer(parseInt(data.data.port)) : await stopServer() return case 'enable_client': data.data.enable ? await connectServer(data.data.host, data.data.authCode) : await disconnectServer() return case 'get_server_status': return getServerStatus() case 'get_client_status': return getClientStatus() case 'generate_code': return generateCode() case 'select_mode': if (selectModeListenr) { selectModeListenr(data.data.mode) selectModeListenr = null } break default: break } }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.sync_get_server_devices, async() => { return getServerDevices() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.sync_remove_server_device, async({ params: clientId }) => { await removeServerDevice(clientId) }) } export const sendSyncAction = (data: LX.Sync.SyncMainWindowActions) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.sync_action, data) } export const sendClientStatus = (status: LX.Sync.ClientStatus) => { sendSyncAction({ action: 'client_status', data: status, }) } export const sendServerStatus = (status: LX.Sync.ServerStatus) => { sendSyncAction({ action: 'server_status', data: status, }) } export const sendSelectMode = (deviceName: string, type: T, listener: (mode: LX.Sync.ModeTypes[T] | null) => void) => { selectModeListenr = listener as typeof selectModeListenr sendSyncAction({ action: 'select_mode', data: { deviceName, type } }) } export const removeSelectModeListener = () => { if (selectModeListenr) selectModeListenr(null) selectModeListenr = null } export const sendCloseSelectMode = () => { sendSyncAction({ action: 'close_select_mode' }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/tx_decodeLyric.ts ================================================ import { createInflate, constants as zlibConstants } from 'node:zlib' // import path from 'path' import { mainHandle } from '@common/mainIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' // eslint-disable-next-line @typescript-eslint/dot-notation, @typescript-eslint/quotes // const require = module[`require`].bind(module) let qrc_decode: (buf: Buffer, len: number) => Buffer const inflate = async(lrcBuf: Buffer) => new Promise((resolve, reject) => { const buffer_builder: Buffer[] = [] const decompress_stream = createInflate() .on('data', (chunk) => { buffer_builder.push(chunk) }) .on('close', () => { resolve(Buffer.concat(buffer_builder).toString()) }) .on('error', (err: any) => { // console.log(err) if (err.errno !== zlibConstants.Z_BUF_ERROR) { // EOF: expected reject(err) } }) // decompress_stream.write(lrcBuf) decompress_stream.end(lrcBuf) }) const decode = async(str: string): Promise => { if (!str) return '' const buf = Buffer.from(str, 'hex') return inflate(qrc_decode(buf, buf.length)) } // 感谢某位不愿透露姓名的大佬提供的C++算法源码,但由于作者不希望公开,所以将会以预构建二进制文件的形式加入代码仓库中 const handleDecode = async(lrc: string, tlrc: string, rlrc: string) => { if (!qrc_decode) { // const nativeBindingPath = path.join(__dirname, '../build/Release/qrc_decode.node') // const nativeBindingPath = process.env.NODE_ENV !== 'production' ? path.join(__dirname, '../build/Release/qrc_decode.node') // eslint-disable-next-line @typescript-eslint/no-var-requires const addon = require('qrc_decode.node') // console.log(addon) qrc_decode = addon.qrc_decode } const [lyric, tlyric, rlyric] = await Promise.all([decode(lrc), decode(tlrc), decode(rlrc)]) return { lyric, tlyric, rlyric, } } export default () => { mainHandle<{ lrc: string, tlrc: string, rlrc: string }, { lyric: string, tlyric: string, rlyric: string }>(WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric, async({ params: { lrc, tlrc, rlrc } }) => { return handleDecode(lrc, tlrc, rlrc) }) } ================================================ FILE: src/main/modules/winMain/rendererEvent/userApi.ts ================================================ import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { mainHandle } from '@common/mainIpc' import { getApiList, importApi, removeApi, setApi, getStatus, request, cancelRequest, setAllowShowUpdateAlert, } from '@main/modules/userApi' import { sendEvent } from '@main/modules/winMain/main' export default () => { mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.import_user_api, async({ params: script }) => { return importApi(script) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.remove_user_api, async({ params: apiIds }) => { return removeApi(apiIds) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.set_user_api, async({ params: apiId }) => { await setApi(apiId) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_user_api_list, async() => { return getApiList() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.get_user_api_status, async() => { return getStatus() }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.user_api_set_allow_update_alert, async({ params: { id, enable } }) => { setAllowShowUpdateAlert(id, enable) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.request_user_api, async({ params }) => { return request(params) }) mainHandle(WIN_MAIN_RENDERER_EVENT_NAME.request_user_api_cancel, async({ params: requestKey }) => { cancelRequest(requestKey) }) } export const sendStatusChange = (status: LX.UserApi.UserApiStatus) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.user_api_status, status) } export const sendShowUpdateAlert = (info: LX.UserApi.UserApiUpdateInfo) => { sendEvent(WIN_MAIN_RENDERER_EVENT_NAME.user_api_show_update_alert, info) } ================================================ FILE: src/main/modules/winMain/utils.ts ================================================ // import fs from 'fs' import path from 'node:path' import { type WindowSize, windowSizeList } from '@common/config' import { nativeImage } from 'electron' export const getWindowSizeInfo = (windowSizeId: number | string): WindowSize => { return windowSizeList.find(i => i.id == windowSizeId) ?? windowSizeList[0] } const getIconPath = (name: string): Electron.NativeImage => { return nativeImage.createFromPath(path.join(global.staticPath, 'images/taskbar', name + '.png')) } export const createTaskBarButtons = ({ empty = false, collect = false, play = false, next = true, prev = true, }: LX.TaskBarButtonFlags, onClick: (action: LX.Player.StatusButtonActions) => void): Electron.ThumbarButton[] => { const buttons: Electron.ThumbarButton[] = [ collect ? { icon: getIconPath('collected'), click() { onClick('unCollect') }, tooltip: '取消收藏', flags: ['nobackground'], } : { icon: getIconPath('collect'), click() { onClick('collect') }, tooltip: '收藏', flags: ['nobackground'], }, { icon: getIconPath('prev'), click() { onClick('prev') }, tooltip: '上一曲', flags: prev ? ['nobackground'] : ['nobackground', 'disabled'], }, play ? { icon: getIconPath('pause'), click() { onClick('pause') }, tooltip: '暂停', flags: ['nobackground'], } : { icon: getIconPath('play'), click() { onClick('play') }, tooltip: '播放', flags: ['nobackground'], }, { icon: getIconPath('next'), click() { onClick('next') }, tooltip: '下一曲', flags: next ? ['nobackground'] : ['nobackground', 'disabled'], }, ] if (empty) { for (const button of buttons) { button.flags = ['nobackground', 'disabled'] } } return buttons } ================================================ FILE: src/main/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "typeRoots": [ "./types" ], "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ "@common/*": ["common/*"], "@main/*": ["main/*"], "@static/*": ["static/*"], "@/*": ["./*"], }, }, } ================================================ FILE: src/main/types/app.d.ts ================================================ /* eslint-disable no-var */ // import { Event as WinMainEvent } from '@main/modules/winMain/event' // import { Event as WinLyricEvent } from '@main/modules/winLyric/event' import { type DislikeType, type AppType, type ListType } from '@main/event' import { type DBSeriveTypes } from '@main/worker/utils' interface Lx { inited: boolean appSetting: LX.AppSetting hotKey: { enable: boolean config: LX.HotKeyConfigAll state: LX.HotKeyState } /** * 是否跳过托盘退出 */ isSkipTrayQuit: boolean /** * main window 是否关闭 */ // mainWindowClosed: boolean event_app: AppType event_list: ListType event_dislike: DislikeType worker: { dbService: DBSeriveTypes } theme: LX.ThemeSetting player_status: LX.Player.Status } declare global { // declare module NodeJS { // export interface Global { // lx: { // app_event: { // winMain: WinMainEvent // winLyric: WinLyricEvent // } // } // } // } // var isDev: boolean var envParams: LX.EnvParams var staticPath: string var lxDataPath: string var lxOldDataPath: string var lx: Lx var appWorder: AppWorder } ================================================ FILE: src/main/types/common.d.ts ================================================ import '@common/types/utils' import '@common/types/app_setting' import '@common/types/common' import '@common/types/user_api' import '@common/types/sync' import '@common/types/list' import '@common/types/list_sync' import '@common/types/download_list' import '@common/types/music' import '@common/types/player' import '@common/types/desktop_lyric' import '@common/types/theme' import '@common/types/ipc_main' import '@common/types/sound_effect' import '@common/types/dislike_list' import '@common/types/dislike_list_sync' import '@common/types/open_api' ================================================ FILE: src/main/types/db_service.d.ts ================================================ declare namespace LX { namespace DBService { interface MusicInfo { id: string listId: string name: string singer: string interval: string | null source: LX.Music.MusicInfo['source'] meta: string order: number } interface MusicInfoOrder { listId: string musicInfoId: string order: number } interface MusicInfoQuery { listId: string } interface MusicInfoRemove { listId: string id: string } interface ListMusicInfoQuery { listId: string musicInfoId: string } interface UserListInfo { id: string name: string source?: LX.OnlineSource sourceListId?: string position: number locationUpdateTime: number | null } type Lyricnfo = { id: string type: 'lyric' text: string source: 'raw' | 'edited' } | { id: string type: keyof Omit text: string | null source: 'raw' | 'edited' } interface MusicUrlInfo { id: string url: string } interface DownloadMusicInfo { id: string isComplate: 0 | 1 status: LX.Download.DownloadTaskStatus statusText: string progress_downloaded: number progress_total: number url: string | null quality: LX.Quality ext: LX.Download.FileExt fileName: string filePath: string musicInfo: string position: number } interface DislikeInfo { // type: 'music' content: string // meta: string | null } interface MusicInfoOtherSource extends Omit { source_id: string order: number } } } ================================================ FILE: src/main/types/global.d.ts ================================================ // // declare module NodeJS { // // interface Global { // // isDev: boolean // // envParams: LX.EnvParams // // staticPath: string // // lx: Lx // // } // // } declare const webpackStaticPath: string declare const webpackUserApiPath: string ================================================ FILE: src/main/types/sync.d.ts ================================================ import type WS from 'ws' type DefaultEventsMap = Record void> declare global { namespace LX { namespace Sync { namespace Client { interface Socket extends WS.WebSocket { isReady: boolean data: { keyInfo: ClientKeyInfo urlInfo: UrlInfo } moduleReadys: { list: boolean dislike: boolean } onClose: (handler: (err: Error) => (void | Promise)) => () => void remote: LX.Sync.ServerSyncActions remoteQueueList: LX.Sync.ServerSyncListActions remoteQueueDislike: LX.Sync.ServerSyncDislikeActions } interface UrlInfo { wsProtocol: string httpProtocol: string hostPath: string href: string } } namespace Server { interface Socket extends WS.WebSocket { isAlive?: boolean isReady: boolean userInfo: { name: 'default' } keyInfo: ServerKeyInfo feature: LX.Sync.EnabledFeatures moduleReadys: { list: boolean dislike: boolean } onClose: (handler: (err: Error) => (void | Promise)) => () => void broadcast: (handler: (client: Socket) => void) => void remote: LX.Sync.ClientSyncActions remoteQueueList: LX.Sync.ClientSyncListActions remoteQueueDislike: LX.Sync.ClientSyncDislikeActions } type SocketServer = WS.Server } } } // interface SyncListActionData_none { // action: 'finished' // } // interface SyncListActionData_getData { // action: 'getData' // data: 'all' // } // type SyncListActionData = SyncListActionData_none | SyncListActionData_getData } ================================================ FILE: src/main/types/sync_common.d.ts ================================================ type WarpSyncHandlerActions = { [K in keyof Actions]: (...args: [Socket, ...Parameters]) => ReturnType } declare namespace LX { namespace Sync { type ServerSyncActions = WarpPromiseRecord<{ onFeatureChanged: (feature: EnabledFeatures) => void }> type ServerSyncHandlerActions = WarpSyncHandlerActions type ServerSyncListActions = WarpPromiseRecord<{ onListSyncAction: (action: LX.Sync.List.ActionList) => void }> type ServerSyncHandlerListActions = WarpSyncHandlerActions type ServerSyncDislikeActions = WarpPromiseRecord<{ onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void }> type ServerSyncHandlerDislikeActions = WarpSyncHandlerActions type ClientSyncActions = WarpPromiseRecord<{ getEnabledFeatures: (serverType: ServerType, supportedFeatures: SupportedFeatures) => EnabledFeatures finished: () => void }> type ClientSyncHandlerActions = WarpSyncHandlerActions type ClientSyncListActions = WarpPromiseRecord<{ onListSyncAction: (action: LX.Sync.List.ActionList) => void list_sync_get_md5: () => string list_sync_get_sync_mode: () => LX.Sync.List.SyncMode list_sync_get_list_data: () => LX.Sync.List.ListData list_sync_set_list_data: (data: LX.Sync.List.ListData) => void list_sync_finished: () => void }> type ClientSyncHandlerListActions = WarpSyncHandlerActions type ClientSyncDislikeActions = WarpPromiseRecord<{ onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void dislike_sync_get_md5: () => string dislike_sync_get_sync_mode: () => LX.Sync.Dislike.SyncMode dislike_sync_get_list_data: () => LX.Dislike.DislikeRules dislike_sync_set_list_data: (data: LX.Dislike.DislikeRules) => void dislike_sync_finished: () => void }> type ClientSyncHandlerDislikeActions = WarpSyncHandlerActions } } ================================================ FILE: src/main/types/worker.d.ts ================================================ import { type workerDBSeriveTypes } from '@main/worker/dbService' declare global { // interface WorkerDBSeriveTypes { // list: typeof list // } namespace LX { type WorkerDBSeriveListTypes = workerDBSeriveTypes } } ================================================ FILE: src/main/utils/fontManage.ts ================================================ // const { getAvailableFontFamilies } = require('electron-font-manager') // exports.getAvailableFontFamilies = getAvailableFontFamilies import { getFonts } from 'font-list' // import { getAvailableFontFamilies } from 'electron-font-manager' // const getFonts = async() => { // switch (process.platform) { // case 'win32': // case 'darwin': // return getAvailableFontFamilies() // default: return getFontsByCommand() // } // } export { getFonts, } ================================================ FILE: src/main/utils/index.ts ================================================ import { encodePath, isUrl, throttle } from '@common/utils' import migrateSetting from '@common/utils/migrateSetting' import getStore from '@main/utils/store' import { STORE_NAMES, URL_SCHEME_RXP } from '@common/constants' import defaultSetting from '@common/defaultSetting' import defaultHotKey from '@common/defaultHotKey' import { migrateDataJson, migrateHotKey, migrateUserApi, parseDataFile } from './migrate' import { nativeTheme, powerSaveBlocker } from 'electron' import { joinPath } from '@common/utils/nodejs' import themes from '@common/theme/index.json' export const parseEnvParams = (argv = process.argv): { cmdParams: LX.CmdParams, deeplink: string | null } => { const cmdParams: LX.CmdParams = {} let deeplink = null const rx = /^-\w+/ for (let param of argv) { if (URL_SCHEME_RXP.test(param)) { deeplink = param } if (!rx.test(param)) continue param = param.substring(1) let index = param.indexOf('=') if (index < 0) { cmdParams[param] = true } else { cmdParams[param.substring(0, index)] = param.substring(index + 1) } } return { cmdParams, deeplink, } } const primitiveType = ['string', 'boolean', 'number'] const checkPrimitiveType = (val: any): boolean => val === null || primitiveType.includes(typeof val) // const handleMergeSetting = (defaultSetting: LX.AppSetting, currentSetting: Partial) => { // const updatedSettingKeys: Array = [] // for (const key of Object.keys(defaultSetting) as Array) { // const currentValue: any = currentSetting[key] // const isPrimitive = checkPrimitiveType(currentValue) // // if (checkPrimitiveType(value)) { // if (!isPrimitive) continue // updatedSettingKeys.push(key) // // @ts-expect-error // defaultSetting[key] = currentValue // // } else { // // if (!isPrimitive && currentValue != undefined) handleMergeSetting(value, currentValue) // // } // } // return { // setting: defaultSetting, // updatedSettingKeys, // } // } export const mergeSetting = (originSetting: LX.AppSetting, targetSetting?: Partial | null): { setting: LX.AppSetting updatedSettingKeys: Array updatedSetting: Partial } => { let originSettingCopy: LX.AppSetting = { ...originSetting } // const defaultVersion = targetSettingCopy.version const updatedSettingKeys: Array = [] const updatedSetting: Partial = {} if (targetSetting) { const originSettingKeys = Object.keys(originSettingCopy) const targetSettingKeys = Object.keys(targetSetting) if (originSettingKeys.length > targetSettingKeys.length) { for (const key of targetSettingKeys as Array) { const targetValue: any = targetSetting[key] const isPrimitive = checkPrimitiveType(targetValue) // if (checkPrimitiveType(value)) { if (!isPrimitive || targetValue == originSettingCopy[key] || originSettingCopy[key] === undefined) continue updatedSettingKeys.push(key) updatedSetting[key] = targetValue // @ts-expect-error originSettingCopy[key] = targetValue // } else { // if (!isPrimitive && currentValue != undefined) handleMergeSetting(value, currentValue) // } } } else { for (const key of originSettingKeys as Array) { const targetValue: any = targetSetting[key] const isPrimitive = checkPrimitiveType(targetValue) // if (checkPrimitiveType(value)) { if (!isPrimitive || targetValue == originSettingCopy[key]) continue updatedSettingKeys.push(key) updatedSetting[key] = targetValue // @ts-expect-error originSettingCopy[key] = targetValue // } else { // if (!isPrimitive && currentValue != undefined) handleMergeSetting(value, currentValue) // } } } } return { setting: originSettingCopy, updatedSettingKeys, updatedSetting, } } const applyInitSetting = (setting: LX.AppSetting) => { if (global.envParams.cmdParams.hidden && !setting['tray.enable']) { setting['tray.enable'] = true } } export const updateSetting = (setting?: Partial, isInit: boolean = false) => { const electronStore_config = getStore(STORE_NAMES.APP_SETTINGS) let originSetting: LX.AppSetting if (isInit) { setting &&= migrateSetting(setting) applyInitSetting(setting as LX.AppSetting) originSetting = { ...defaultSetting } } else originSetting = global.lx.appSetting const result = mergeSetting(originSetting, setting) result.setting.version = defaultSetting.version electronStore_config.override({ version: result.setting.version, setting: result.setting }) return result } /** * 初始化设置 */ export const initSetting = async() => { const electronStore_config = getStore(STORE_NAMES.APP_SETTINGS) let setting = electronStore_config.get('setting') as LX.AppSetting | undefined // migrate setting if (!setting) { const config = await parseDataFile<{ setting?: any }>('config.json') if (config?.setting) setting = config.setting as LX.AppSetting await migrateUserApi() await migrateDataJson() } // console.log(setting) return updateSetting(setting, true) } /** * 初始化快捷键设置 */ export const initHotKey = async() => { const electronStore_hotKey = getStore(STORE_NAMES.HOTKEY) let localConfig = electronStore_hotKey.get('local') as LX.HotKeyConfig | null let globalConfig = electronStore_hotKey.get('global') as LX.HotKeyConfig | null if (globalConfig) { // 移除v2.2.0及之前设置的全局媒体快捷键注册 if (globalConfig.keys.MediaPlayPause) { delete globalConfig.keys.MediaPlayPause delete globalConfig.keys.MediaNextTrack delete globalConfig.keys.MediaPreviousTrack electronStore_hotKey.set('global', globalConfig) } } else { // migrate hotKey const config = await migrateHotKey() if (config) { localConfig = config.local globalConfig = config.global } else { localConfig = JSON.parse(JSON.stringify(defaultHotKey.local)) globalConfig = JSON.parse(JSON.stringify(defaultHotKey.global)) } electronStore_hotKey.set('local', localConfig) electronStore_hotKey.set('global', globalConfig) } return { local: localConfig!, global: globalConfig!, } } type HotKeyType = 'local' | 'global' const saveHotKeyConfig = throttle<[LX.HotKeyConfigAll]>((config: LX.HotKeyConfigAll) => { for (const key of Object.keys(config) as HotKeyType[]) { global.lx.hotKey.config[key] = config[key] getStore(STORE_NAMES.HOTKEY).set(key, config[key]) } }) export const saveAppHotKeyConfig = (config: LX.HotKeyConfigAll) => { saveHotKeyConfig(config) } export const openDevTools = (webContents: Electron.WebContents) => { webContents.openDevTools({ mode: 'undocked', }) } let userThemes: LX.Theme[] export const getAllThemes = () => { userThemes ??= getStore(STORE_NAMES.THEME).get('themes') as (LX.Theme[] | null) ?? [] return { themes, userThemes, dataPath: joinPath(global.lxDataPath, 'theme_images'), } } export const saveTheme = (theme: LX.Theme) => { const targetTheme = userThemes.find(t => t.id === theme.id) if (targetTheme) Object.assign(targetTheme, theme) else userThemes.push(theme) getStore(STORE_NAMES.THEME).set('themes', userThemes) } export const removeTheme = (id: string) => { const index = userThemes.findIndex(t => t.id === id) if (index < 0) return userThemes.splice(index, 1) getStore(STORE_NAMES.THEME).set('themes', userThemes) } const copyTheme = (theme: LX.Theme): LX.Theme => { return { ...theme, config: { ...theme.config, extInfo: { ...theme.config.extInfo }, themeColors: { ...theme.config.themeColors }, }, } } export const getTheme = () => { // fs.promises.readdir() const shouldUseDarkColors = nativeTheme.shouldUseDarkColors let themeId = global.lx.appSetting['theme.id'] == 'auto' ? shouldUseDarkColors ? global.lx.appSetting['theme.darkId'] : global.lx.appSetting['theme.lightId'] : global.lx.appSetting['theme.id'] // themeId = 'naruto' // themeId = 'pink' // themeId = 'black' let theme = themes.find(theme => theme.id == themeId) if (!theme) { userThemes = getStore(STORE_NAMES.THEME).get('themes') as LX.Theme[] | null ?? [] theme = userThemes.find(theme => theme.id == themeId) if (theme) { if (theme.config.extInfo['--background-image'] != 'none') { theme = copyTheme(theme) theme.config.extInfo['--background-image'] = isUrl(theme.config.extInfo['--background-image']) ? `url(${theme.config.extInfo['--background-image']})` : `url(file:///${encodePath(joinPath(global.lxDataPath, 'theme_images', theme.config.extInfo['--background-image']))})` } } else { themeId = global.lx.appSetting['theme.id'] == 'auto' && shouldUseDarkColors ? 'black' : 'green' theme = themes.find(theme => theme.id == themeId) as LX.Theme } } const colors: Record = { ...theme.config.themeColors, ...theme.config.extInfo, } return { shouldUseDarkColors, theme: { id: global.lx.appSetting['theme.id'], name: theme.name, isDark: theme.isDark, isDarkFont: theme.isDarkFont, colors, }, } } let powerSaveBlockerId: number | null = null export const setPowerSaveBlocker = (enabled: boolean) => { let isEnabled = powerSaveBlockerId != null && powerSaveBlocker.isStarted(powerSaveBlockerId) if (enabled) { if (isEnabled) return powerSaveBlockerId = powerSaveBlocker.start('prevent-app-suspension') } else { if (!isEnabled) return powerSaveBlocker.stop(powerSaveBlockerId!) powerSaveBlockerId = null } } let envProxy: null | { host: string, port: number } = null export const getProxy = () => { if (global.lx.appSetting['network.proxy.enable'] && global.lx.appSetting['network.proxy.host']) { return { host: global.lx.appSetting['network.proxy.host'], port: parseInt(global.lx.appSetting['network.proxy.port'] || '80'), } } if (envProxy) { return { host: envProxy.host, port: envProxy.port, } } else { const envProxyStr = envParams.cmdParams['proxy-server'] if (envProxyStr && typeof envProxyStr == 'string') { const [host, port = ''] = envProxyStr.split(':') return envProxy = { host, port: parseInt(port || '80'), } } } return null } ================================================ FILE: src/main/utils/logInit.ts ================================================ import log from 'electron-log/node' log.transports.file.level = 'info' // log.initialize() ================================================ FILE: src/main/utils/migrate.ts ================================================ import fs from 'node:fs' import { checkPath, joinPath } from '@common/utils/nodejs' import { log } from '@common/utils' import { filterMusicList, toNewMusicInfo } from '@common/utils/tools' import { APP_EVENT_NAMES, STORE_NAMES } from '@common/constants' /** * 读取配置文件 * @returns */ export const parseDataFile = async(name: string): Promise => { const path = joinPath(global.lxOldDataPath, name) if (await checkPath(path)) { try { return JSON.parse((await fs.promises.readFile(path)).toString()) } catch (err) { log.error(err) } } return null } interface OldUserListInfo { name: string id: string source?: LX.OnlineSource sourceListId?: string locationUpdateTime?: number list: any[] } /** * 迁移 v2.0.0 之前的 list data * @returns */ export const migrateDBData = async() => { let playList = await parseDataFile<{ defaultList?: { list: any[] }, loveList?: { list: any[] }, tempList?: { list: any[] }, userList?: OldUserListInfo[] }>('playList.json') let listDataAll: LX.List.ListDataFull = { defaultList: [], loveList: [], userList: [], tempList: [], } let isRequiredSave = false if (playList) { if (playList.defaultList) listDataAll.defaultList = filterMusicList(playList.defaultList.list.map(m => toNewMusicInfo(m))) if (playList.loveList) listDataAll.loveList = filterMusicList(playList.loveList.list.map(m => toNewMusicInfo(m))) if (playList.tempList) listDataAll.tempList = filterMusicList(playList.tempList.list.map(m => toNewMusicInfo(m))) if (playList.userList) { listDataAll.userList = playList.userList.map(l => { return { ...l, locationUpdateTime: l.locationUpdateTime ?? null, list: filterMusicList(l.list.map(m => toNewMusicInfo(m))), } }) } isRequiredSave = true } else { const config = await parseDataFile<{ list?: { defaultList?: any[], loveList?: any[] } }>('config.json') if (config?.list) { const list = config.list if (list.defaultList) listDataAll.defaultList = filterMusicList(list.defaultList.map(m => toNewMusicInfo(m))) if (list.loveList) listDataAll.loveList = filterMusicList(list.loveList.map(m => toNewMusicInfo(m))) isRequiredSave = true } } if (isRequiredSave) await global.lx.worker.dbService.listDataOverwrite(listDataAll) const lyricData = await parseDataFile>('lyrics_edited.json') if (lyricData) { for await (const [id, info] of Object.entries(lyricData)) { await global.lx.worker.dbService.editedLyricAdd(id, info) } } } // 迁移文件 const migrateFile = async(name: string, targetName: string) => { let path = joinPath(global.lxDataPath, targetName) let oldPath = joinPath(global.lxOldDataPath, name) if (!await checkPath(path) && await checkPath(oldPath)) { await fs.promises.copyFile(oldPath, path).catch(err => { log.error(err) }).catch(err => { log.error(err) }) } } /** * 迁移 v2.0.0 之前的 data.json * @returns */ export const migrateDataJson = async() => { const path = joinPath(global.lxDataPath, 'data.json') if (await checkPath(path)) return const oldDataFile = await parseDataFile<{ searchHistoryList?: string[] playInfo?: any listPrevSelectId?: any listPosition?: any listUpdateInfo?: any }>('data.json') if (!oldDataFile) return const newData: any = {} if (oldDataFile.searchHistoryList) newData.searchHistoryList = oldDataFile.searchHistoryList if (oldDataFile.playInfo) newData.playInfo = oldDataFile.playInfo if (oldDataFile.listPrevSelectId) newData.listPrevSelectId = oldDataFile.listPrevSelectId if (oldDataFile.listPosition) newData.listScrollPosition = oldDataFile.listPosition if (oldDataFile.listUpdateInfo) newData.listUpdateInfo = oldDataFile.listUpdateInfo await fs.promises.writeFile(path, JSON.stringify(newData)).catch(err => { log.error(err) }) } const hotKeyNameMap = { mainWindow: APP_EVENT_NAMES.winMainName, winLyric: APP_EVENT_NAMES.winLyricName, } as const const updateHotKeyTypeName = (config: LX.HotKeyConfig) => { for (const keyConfig of Object.values(config.keys)) { if (hotKeyNameMap[keyConfig.type as keyof typeof hotKeyNameMap]) keyConfig.type = hotKeyNameMap[keyConfig.type as keyof typeof hotKeyNameMap] } } /** * 迁移 v2.0.0 之前的 hotkey * @returns */ export const migrateHotKey = async() => { const oldConfig = await parseDataFile('hotKey.json') if (oldConfig) { let localConfig: LX.HotKeyConfig let globalConfig: LX.HotKeyConfig updateHotKeyTypeName(oldConfig.local) updateHotKeyTypeName(oldConfig.global) localConfig = oldConfig.local globalConfig = oldConfig.global // 移除v1.0.1及之前设置的全局声音媒体快捷键接管 if (globalConfig.keys.VolumeUp) { delete globalConfig.keys.VolumeUp delete globalConfig.keys.VolumeDown delete globalConfig.keys.VolumeMute } return { local: localConfig, global: globalConfig, } } return null } /** * 迁移 v2.0.0 之前的user api * @returns */ export const migrateUserApi = async() => migrateFile('userApi.json', STORE_NAMES.USER_API + '.json') ================================================ FILE: src/main/utils/request.ts ================================================ // import progress from 'request-progress' import { request, type Options } from '@common/utils/request' // import fs from 'fs' export const requestMsg = { fail: '请求异常😮,可以多试几次,若还是不行就换一首吧。。。', unachievable: '哦No😱...接口无法访问了!', timeout: '请求超时', // unachievable: '哦No😱...接口无法访问了!已帮你切换到临时接口,重试下看能不能播放吧~', notConnectNetwork: '无法连接到服务器', cancelRequest: '取消http请求', } as const // var proxyUrl = "http://" + user + ":" + password + "@" + host + ":" + port; // var proxiedRequest = request.defaults({'proxy': proxyUrl}); // interface RequestPromise extends Promise { // abort: () => void // } /** * 请求超时自动重试 * @param {*} url * @param {*} options */ export const httpFetch = async (url: string, options: Options) => { return request(url, options).catch(async(err: any) => { // console.log('出错', err) if (err.message === 'socket hang up') { // window.globalObj.apiSource = 'temp' throw new Error(requestMsg.unachievable) } switch (err.code) { case 'ETIMEDOUT': case 'ESOCKETTIMEDOUT': throw new Error(requestMsg.timeout) case 'ENOTFOUND': throw new Error(requestMsg.notConnectNetwork) default: throw err } }) // requestObj.promise = requestObj.promise.catch(async err => { // // console.log('出错', err) // if (err.message === 'socket hang up') { // // window.globalObj.apiSource = 'temp' // return Promise.reject(new Error(requestMsg.unachievable)) // } // switch (err.code) { // case 'ETIMEDOUT': // case 'ESOCKETTIMEDOUT': // return Promise.reject(new Error(requestMsg.timeout)) // case 'ENOTFOUND': // return Promise.reject(new Error(requestMsg.notConnectNetwork)) // default: // return Promise.reject(err) // } // }) // return requestPromise } export type RequestOptions = Options ================================================ FILE: src/main/utils/store.ts ================================================ // import { writeFileSync } from 'atomically' import { dialog, shell } from 'electron' import path from 'node:path' import fs from 'node:fs' import { log } from '@common/utils' type Stores = Record const stores: Stores = {} class Store { private readonly filePath: string private readonly dirPath: string private store: Record private writeFile() { const tempPath = this.filePath + '.' + Math.random().toString().substring(2, 10) + '.temp' try { fs.writeFileSync(tempPath, JSON.stringify(this.store, null, '\t'), 'utf8') } catch (err: any) { if (err.code === 'ENOENT') { fs.mkdirSync(this.dirPath, { recursive: true }) fs.writeFileSync(tempPath, JSON.stringify(this.store, null, '\t'), 'utf8') } else throw err } fs.renameSync(tempPath, this.filePath) } constructor(filePath: string, clearInvalidConfig: boolean = false) { this.filePath = filePath this.dirPath = path.dirname(this.filePath) let store: Record if (fs.existsSync(this.filePath)) { if (clearInvalidConfig) { try { store = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) } catch { store = {} } } else store = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) } else store = {} if (typeof store != 'object') { if (clearInvalidConfig) store = {} else throw new Error('parse data error: ' + String(store)) } this.store = store } get(key: string): Value { return this.store[key] } has(key: string): boolean { return key in this.store } set(key: string, value: any) { this.store[key] = value this.writeFile() } override(value: Record) { this.store = value this.writeFile() } } /** * 获取 Store 对象 * @param name store 名 * @param isIgnoredError 是否忽略错误 * @param isShowErrorAlert=true 是否显示错误弹窗 * @returns Store */ export default (name: string, isIgnoredError = true, isShowErrorAlert = true): Store => { if (stores[name]) return stores[name] let store: Store const storePath = path.join(global.lxDataPath, name + '.json') try { store = stores[name] = new Store(storePath, false) } catch (err: any) { const error = err as Error log.error(error) if (!isIgnoredError) throw error const backPath = storePath + '.bak' fs.renameSync(storePath, backPath) if (isShowErrorAlert) { dialog.showMessageBoxSync({ type: 'error', message: name + ' data load error', detail: `We have helped you back up the old ${name} file to: ${backPath}\nYou can try to repair and restore it manually\n\nError detail: ${error.message}`, }) shell.showItemInFolder(backPath) } store = new Store(storePath, true) } return store } export { Store, } ================================================ FILE: src/main/worker/dbService/db.ts ================================================ import Database from 'better-sqlite3' import path from 'path' import tables, { DB_VERSION } from './tables' import verifyDB from './verifyDB' import migrateData from './migrate' let db: Database.Database const initTables = (db: Database.Database) => { db.exec(` ${Array.from(tables.values()).join('\n')} INSERT INTO "main"."db_info" ("field_name", "field_value") VALUES ('version', '${DB_VERSION}'); `) } // 打开、初始化数据库 export const init = (lxDataPath: string): boolean | null => { const databasePath = path.join(lxDataPath, 'lx.data.db') const nativeBinding = path.join(__dirname, '../node_modules/better-sqlite3/build/Release/better_sqlite3.node') let dbFileExists = true try { db = new Database(databasePath, { fileMustExist: true, nativeBinding, // verbose: process.env.NODE_ENV !== 'production' ? console.log : undefined, }) } catch (error) { console.log(error) db = new Database(databasePath, { nativeBinding, // verbose: process.env.NODE_ENV !== 'production' ? console.log : undefined, }) initTables(db) dbFileExists = false } db.pragma('journal_mode = WAL') if (dbFileExists) migrateData(db) // https://www.sqlite.org/pragma.html#pragma_optimize if (dbFileExists) db.exec('PRAGMA optimize;') if (!verifyDB(db)) { db.close() return null } // https://www.sqlite.org/lang_vacuum.html // db.exec('VACUUM "main"') process.on('exit', () => db.close()) console.log('db inited') // require('./test') return dbFileExists } // 获取数据库实例 export const getDB = (): Database.Database => db ================================================ FILE: src/main/worker/dbService/index.ts ================================================ import { init } from './db' import { exposeWorker } from '../utils/worker' import { list, lyric, music_url, music_other_source, download, dislike_list } from './modules/index' const common = { init, } exposeWorker(Object.assign(common, list, lyric, music_url, music_other_source, download, dislike_list)) export type workerDBSeriveTypes = typeof common & typeof list & typeof lyric & typeof music_url & typeof music_other_source & typeof download & typeof dislike_list ================================================ FILE: src/main/worker/dbService/migrate.ts ================================================ import type Database from 'better-sqlite3' import tables, { DB_VERSION } from './tables' // const migrateV1 = (db: Database.Database) => { // const sql = ` // DROP TABLE "main"."download_list"; // CREATE TABLE "download_list" ( // "id" TEXT NOT NULL, // "isComplate" INTEGER NOT NULL, // "status" TEXT NOT NULL, // "statusText" TEXT NOT NULL, // "progress_downloaded" INTEGER NOT NULL, // "progress_total" INTEGER NOT NULL, // "url" TEXT, // "quality" TEXT NOT NULL, // "ext" TEXT NOT NULL, // "fileName" TEXT NOT NULL, // "filePath" TEXT NOT NULL, // "musicInfo" TEXT NOT NULL, // "position" INTEGER NOT NULL, // PRIMARY KEY("id") // ); // ` // db.exec(sql) // db.prepare('UPDATE "main"."db_info" SET "field_value"=@value WHERE "field_name"=@name').run({ name: 'version', value: '2' }) // } const migrateV1 = (db: Database.Database) => { // 修复 v2.4.0 的默认数据库版本号不对的问题 const existsTable = db.prepare('SELECT name FROM "main".sqlite_master WHERE type=\'table\' AND name=\'dislike_list\';').get() if (!existsTable) { const sql = tables.get('dislike_list')! db.exec(sql) } } export default (db: Database.Database) => { // PRAGMA user_version = x // console.log(db.prepare('PRAGMA user_version').get().user_version) // https://github.com/WiseLibs/better-sqlite3/issues/668#issuecomment-1145285728 const version = (db.prepare<[string]>('SELECT "field_value" FROM "main"."db_info" WHERE "field_name" = ?').get('version') as { field_value: string }).field_value switch (version) { case '1': migrateV1(db) db.prepare('UPDATE "main"."db_info" SET "field_value"=@value WHERE "field_name"=@name').run({ name: 'version', value: DB_VERSION }) break } } ================================================ FILE: src/main/worker/dbService/modules/dislike_list/dbHelper.ts ================================================ // import type Database from 'better-sqlite3' import { getDB } from '../../db' import { createQueryStatement, createInsertStatement, // createDeleteStatement, // createUpdateStatement, createClearStatement, } from './statements' /** * 查询不喜欢歌曲列表 */ export const queryDislikeList = () => { const queryStatement = createQueryStatement() return queryStatement.all() as LX.DBService.DislikeInfo[] } /** * 批量插入不喜欢歌曲并刷新顺序 * @param infos 列表 */ export const insertDislikeList = async(infos: LX.DBService.DislikeInfo[]) => { const db = getDB() const insertStatement = createInsertStatement() db.transaction((infos: LX.DBService.DislikeInfo[]) => { for (const info of infos) insertStatement.run(info) })(infos) } /** * 覆盖并批量插入不喜欢歌曲并刷新顺序 * @param infos 列表 */ export const overwirteDislikeList = async(infos: LX.DBService.DislikeInfo[]) => { const db = getDB() const clearStatement = createClearStatement() const insertStatement = createInsertStatement() db.transaction((infos: LX.DBService.DislikeInfo[]) => { clearStatement.run() for (const info of infos) insertStatement.run(info) })(infos) } // /** // * 批量删除不喜欢歌曲 // * @param ids 列表 // */ // export const deleteDislikeList = (ids: string[]) => { // const db = getDB() // const deleteStatement = createDeleteStatement() // db.transaction((ids: string[]) => { // for (const id of ids) deleteStatement.run(BigInt(id)) // })(ids) // } // /** // * 批量更新不喜欢歌曲 // * @param urlInfo 列表 // */ // export const updateDislikeList = async(infos: LX.DBService.DislikeInfo[]) => { // const db = getDB() // const updateStatement = createUpdateStatement() // db.transaction((infos: LX.DBService.DislikeInfo[]) => { // for (const info of infos) updateStatement.run(info) // })(infos) // } // /** // * 清空不喜欢歌曲列表 // */ // export const clearDislikeList = () => { // const clearStatement = createClearStatement() // clearStatement.run() // } ================================================ FILE: src/main/worker/dbService/modules/dislike_list/index.ts ================================================ import { SPLIT_CHAR } from '@common/constants' import { queryDislikeList, insertDislikeList, overwirteDislikeList, // updateDislikeList, // deleteDislikeList, // clearDislikeList, } from './dbHelper' // let dislikeInfo: LX.Dislike.DislikeInfo const toDBDislikeInfo = (musicInfos: string[]): LX.DBService.DislikeInfo[] => { const list: LX.DBService.DislikeInfo[] = [] for (const item of musicInfos) { if (!item.trim()) continue list.push({ content: item, }) } return list } const initDislikeList = () => { const dislikeInfo: LX.Dislike.DislikeInfo = { // musicIds: new Set(), names: new Set(), singerNames: new Set(), musicNames: new Set(), rules: '', } const list: string[] = [] for (const item of queryDislikeList()) { if (!item) continue let [name, singer] = item.content.split(SPLIT_CHAR.DISLIKE_NAME) if (name) { name = name.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() const rule = `${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}` dislikeInfo.names.add(rule) list.push(rule) } else { dislikeInfo.musicNames.add(name) list.push(name) } } else if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() dislikeInfo.singerNames.add(singer) list.push(`${SPLIT_CHAR.DISLIKE_NAME}${singer}`) } } dislikeInfo.rules = Array.from(new Set(list)).join('\n') return dislikeInfo } /** * 获取不喜欢列表信息 * @returns 不喜欢列表信息 */ export const getDislikeListInfo = (): LX.Dislike.DislikeInfo => { // if (!dislikeInfo) initDislikeList() return initDislikeList() } /** * 添加信息 * @param lists 列表信息 */ export const dislikeInfoAdd = async(lists: LX.Dislike.DislikeMusicInfo[]) => { await insertDislikeList(lists.map(info => ({ content: `${info.name}${SPLIT_CHAR.DISLIKE_NAME}${info.singer}` }))) } /** * 覆盖列表信息 * @param rules 规则信息 */ export const dislikeInfoOverwrite = async(rules: string) => { await overwirteDislikeList(toDBDislikeInfo(rules.split('\n'))) } // /** // * 删除不喜欢列表 // * @param ids 歌曲id // */ // export const dislikeInfoRemove = (ids: string[]) => { // deleteDislikeList(ids) // } // /** // * 清空不喜欢列表 // */ // export const dislikeInfoClear = () => { // clearDislikeList() // } ================================================ FILE: src/main/worker/dbService/modules/dislike_list/statements.ts ================================================ import { getDB } from '../../db' /** * 创建不喜欢列表查询语句 * @returns 查询语句 */ export const createQueryStatement = () => { const db = getDB() return db.prepare<[]>(` SELECT "content" FROM dislike_list WHERE "type"='music' `) } /** * 创建不喜欢记录插入语句 * @returns 插入语句 */ export const createInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.DislikeInfo]>(` INSERT INTO "main"."dislike_list" ("type", "content") VALUES ('music', @content)`) } /** * 创建不喜欢记录清空语句 * @returns 清空语句 */ export const createClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."dislike_list" `) } // /** // * 创建不喜欢记录删除语句 // * @returns 删除语句 // */ // export const createDeleteStatement = () => { // const db = getDB() // return db.prepare<[bigint]>(` // DELETE FROM "main"."dislike_list" // WHERE "id"=? // `) // } // /** // * 创建不喜欢记录更新语句 // * @returns 更新语句 // */ // export const createUpdateStatement = () => { // const db = getDB() // return db.prepare<[LX.DBService.DislikeInfo]>(` // UPDATE "main"."dislike_list" // SET "name"=@name, "singer"=@singer // WHERE "id"=@id // `) // } ================================================ FILE: src/main/worker/dbService/modules/download/dbHelper.ts ================================================ import { getDB } from '../../db' import { createQueryStatement, createInsertStatement, createDeleteStatement, createUpdateStatement, createUpdatePositionStatement, createClearStatement, } from './statements' /** * 查询下载歌曲列表 */ export const queryDownloadList = () => { const queryStatement = createQueryStatement() return queryStatement.all() as LX.DBService.DownloadMusicInfo[] } /** * 批量插入下载歌曲并刷新顺序 * @param mInfos 列表 */ export const insertDownloadList = (mInfos: LX.DBService.DownloadMusicInfo[], listPositions: Array<{ id: string, position: number }>) => { const db = getDB() const insertStatement = createInsertStatement() const updatePositionStatement = createUpdatePositionStatement() db.transaction((mInfos: LX.DBService.DownloadMusicInfo[]) => { for (const info of mInfos) insertStatement.run(info) for (const info of listPositions) updatePositionStatement.run(info) })(mInfos) } /** * 批量删除下载歌曲 * @param ids 列表 */ export const deleteDownloadList = (ids: string[]) => { const db = getDB() const deleteStatement = createDeleteStatement() db.transaction((ids: string[]) => { for (const id of ids) deleteStatement.run(id) })(ids) } /** * 批量更新下载歌曲 * @param urlInfo 列表 */ export const updateDownloadList = (urlInfo: LX.DBService.DownloadMusicInfo[]) => { const db = getDB() const updateStatement = createUpdateStatement() db.transaction((urlInfo: LX.DBService.DownloadMusicInfo[]) => { for (const info of urlInfo) updateStatement.run(info) })(urlInfo) } /** * 清空下载歌曲列表 */ export const clearDownloadList = () => { const clearStatement = createClearStatement() clearStatement.run() } ================================================ FILE: src/main/worker/dbService/modules/download/index.ts ================================================ import { arrPush, arrUnshift } from '@common/utils/common' import { queryDownloadList, insertDownloadList, updateDownloadList, deleteDownloadList, clearDownloadList, } from './dbHelper' let list: LX.Download.ListItem[] const toDBDownloadInfo = (musicInfos: LX.Download.ListItem[], offset: number = 0): LX.DBService.DownloadMusicInfo[] => { return musicInfos.map((info, index) => { return { id: info.id, isComplate: info.isComplate ? 1 : 0, status: info.status, statusText: info.statusText, progress_downloaded: info.downloaded, progress_total: info.total, url: info.metadata.url, quality: info.metadata.quality, ext: info.metadata.ext, fileName: info.metadata.fileName, filePath: info.metadata.filePath, musicInfo: JSON.stringify(info.metadata.musicInfo), position: offset + index, } }) } const initDownloadList = () => { list = queryDownloadList().map(item => { const musicInfo = JSON.parse(item.musicInfo) as LX.Music.MusicInfoOnline return { id: item.id, isComplate: item.isComplate == 1, status: item.status, statusText: item.statusText, downloaded: item.progress_downloaded, total: item.progress_total, progress: item.progress_total ? parseInt((item.progress_downloaded / item.progress_total).toFixed(2)) * 100 : 0, speed: '', writeQueue: 0, metadata: { musicInfo, url: item.url, quality: item.quality, ext: item.ext, fileName: item.fileName, filePath: item.filePath, }, } }) } /** * 获取下载列表 * @returns 下载列表 */ export const getDownloadList = (): LX.Download.ListItem[] => { if (!list) initDownloadList() return list } /** * 添加下载歌曲信息 * @param downloadInfos url信息 */ export const downloadInfoSave = (downloadInfos: LX.Download.ListItem[], addMusicLocationType: LX.AddMusicLocationType) => { if (!list) initDownloadList() if (addMusicLocationType == 'top') { let newList = [...list] arrUnshift(newList, downloadInfos) insertDownloadList(toDBDownloadInfo(downloadInfos), newList.slice(downloadInfos.length - 1).map((info, index) => { return { id: info.id, position: index } })) list = newList } else { insertDownloadList(toDBDownloadInfo(downloadInfos, list.length), []) arrPush(list, downloadInfos) } } /** * 批量更新列表信息 * @param lists 列表信息 */ export const downloadInfoUpdate = (lists: LX.Download.ListItem[]) => { updateDownloadList(toDBDownloadInfo(lists)) if (list) { for (const item of lists) { const index = list.findIndex(info => info.id === item.id) if (index < 0) continue list.splice(index, 1, item) } } } /** * 删除下载列表 * @param ids 歌曲id */ export const downloadInfoRemove = (ids: string[]) => { deleteDownloadList(ids) if (list) { const idSet = new Set(ids) list = list.filter(task => !idSet.has(task.id)) } } /** * 清空下载列表 */ export const downloadInfoClear = () => { clearDownloadList() } ================================================ FILE: src/main/worker/dbService/modules/download/statements.ts ================================================ import { getDB } from '../../db' /** * 创建下载列表查询语句 * @returns 查询语句 */ export const createQueryStatement = () => { const db = getDB() return db.prepare<[]>(` SELECT "id", "isComplate", "status", "statusText", "progress_downloaded", "progress_total", "url", "quality", "ext", "fileName", "filePath", "musicInfo", "position" FROM download_list ORDER BY "position" ASC `) } /** * 创建下载记录插入语句 * @returns 插入语句 */ export const createInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.DownloadMusicInfo]>(` INSERT INTO "main"."download_list" ("id", "isComplate", "status", "statusText", "progress_downloaded", "progress_total", "url", "quality", "ext", "fileName", "filePath", "musicInfo", "position") VALUES (@id, @isComplate, @status, @statusText, @progress_downloaded, @progress_total, @url, @quality, @ext, @fileName, @filePath, @musicInfo, @position)`) } /** * 创建下载记录清空语句 * @returns 清空语句 */ export const createClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."download_list" `) } /** * 创建下载记录删除语句 * @returns 删除语句 */ export const createDeleteStatement = () => { const db = getDB() return db.prepare<[string]>(` DELETE FROM "main"."download_list" WHERE "id"=? `) } /** * 创建下载记录更新语句 * @returns 更新语句 */ export const createUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.DownloadMusicInfo]>(` UPDATE "main"."download_list" SET "isComplate"=@isComplate, "status"=@status, "statusText"=@statusText, "progress_downloaded"=@progress_downloaded, "progress_total"=@progress_total, "url"=@url, "filePath"=@filePath WHERE "id"=@id`) } /** * 创建下载记录顺序更新语句 * @returns 更新语句 */ export const createUpdatePositionStatement = () => { const db = getDB() return db.prepare<[{ id: string, position: number }]>(` UPDATE "main"."download_list" SET "position"=@position WHERE "id"=@id`) } ================================================ FILE: src/main/worker/dbService/modules/index.ts ================================================ export * as list from './list' export * as lyric from './lyric' export * as music_url from './music_url' export * as music_other_source from './music_other_source' export * as download from './download' export * as dislike_list from './dislike_list' ================================================ FILE: src/main/worker/dbService/modules/list/dbHelper.ts ================================================ import { getDB } from '../../db' import { createListQueryStatement, createListInsertStatement, createListDeleteStatement, createListClearStatement, createListUpdateStatement, createMusicInfoQueryStatement, createMusicInfoInsertStatement, createMusicInfoUpdateStatement, createMusicInfoDeleteStatement, createMusicInfoDeleteByListIdStatement, createMusicInfoOrderInsertStatement, createMusicInfoOrderDeleteStatement, createMusicInfoOrderDeleteByListIdStatement, createMusicInfoClearStatement, createMusicInfoOrderClearStatement, createMusicInfoByListAndMusicInfoIdQueryStatement, createMusicInfoByMusicInfoIdQueryStatement, } from './statements' const idFixRxp = /\.0$/ /** * 获取用户列表 * @returns */ export const queryAllUserList = () => { const list = createListQueryStatement().all() as LX.DBService.UserListInfo[] for (const info of list) { // 兼容v2.3.0之前版本插入数字类型的ID导致其意外在末尾追加 .0 的问题 if (info.sourceListId?.endsWith?.('.0')) { info.sourceListId = info.sourceListId.replace(idFixRxp, '') } } return list } /** * 批量插入用户列表 * @param lists 列表 * @param isClear 是否清空列表 */ export const insertUserLists = (lists: LX.DBService.UserListInfo[], isClear: boolean = false) => { const db = getDB() const listClearStatement = createListClearStatement() const listInsertStatement = createListInsertStatement() db.transaction((lists: LX.DBService.UserListInfo[]) => { if (isClear) listClearStatement.run() for (const list of lists) { listInsertStatement.run({ id: list.id, name: list.name, source: list.source, sourceListId: list.sourceListId, locationUpdateTime: list.locationUpdateTime, position: list.position, }) } })(lists) } /** * 批量删除用户列表及列表内歌曲 * @param listIds 列表id */ export const deleteUserLists = (listIds: string[]) => { const db = getDB() const listDeleteStatement = createListDeleteStatement() const musicInfoDeleteByListIdStatement = createMusicInfoDeleteByListIdStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() db.transaction((listIds: string[]) => { for (const id of listIds) { listDeleteStatement.run(id) musicInfoDeleteByListIdStatement.run(id) musicInfoOrderDeleteByListIdStatement.run(id) } })(listIds) } /** * 批量更新用户列表 * @param lists 列表 */ export const updateUserLists = (lists: LX.DBService.UserListInfo[]) => { const db = getDB() const listUpdateStatement = createListUpdateStatement() db.transaction((lists: LX.DBService.UserListInfo[]) => { for (const list of lists) listUpdateStatement.run(list) })(lists) } /** * 批量添加歌曲 * @param list */ export const insertMusicInfoList = (list: LX.DBService.MusicInfo[]) => { const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() const db = getDB() db.transaction((musics: LX.DBService.MusicInfo[]) => { for (const music of musics) { musicInfoInsertStatement.run(music) musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } })(list) } /** * 批量添加歌曲并刷新排序 * @param list 新增歌曲 * @param listId 列表Id * @param listAll 原始列表歌曲,列表去重后 */ export const insertMusicInfoListAndRefreshOrder = (list: LX.DBService.MusicInfo[], listId: string, listAll: LX.DBService.MusicInfo[]) => { const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() const db = getDB() db.transaction((list: LX.DBService.MusicInfo[], listId: string, listAll: LX.DBService.MusicInfo[]) => { musicInfoOrderDeleteByListIdStatement.run(listId) for (const music of list) { musicInfoInsertStatement.run(music) musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } for (const music of listAll) { musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } })(list, listId, listAll) } /** * 批量更新歌曲 * @param list */ export const updateMusicInfos = (list: LX.DBService.MusicInfo[]) => { const musicInfoUpdateStatement = createMusicInfoUpdateStatement() const db = getDB() db.transaction((musics: LX.DBService.MusicInfo[]) => { for (const music of musics) { musicInfoUpdateStatement.run(music) } })(list) } /** * 获取列表内的歌曲 * @param listId 列表Id * @returns 列表歌曲 */ export const queryMusicInfoByListId = (listId: string) => { const musicInfoQueryStatement = createMusicInfoQueryStatement() return musicInfoQueryStatement.all({ listId }) as LX.DBService.MusicInfo[] } /** * 批量移动歌曲 * @param fromId 源列表Id * @param ids 要移动的歌曲 * @param musicInfos 音乐信息 */ export const moveMusicInfo = (fromId: string, ids: string[], musicInfos: LX.DBService.MusicInfo[]) => { const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() const musicInfoDeleteStatement = createMusicInfoDeleteStatement() const musicInfoOrderDeleteStatement = createMusicInfoOrderDeleteStatement() // const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() const db = getDB() db.transaction((fromId: string, ids: string[], musicInfos: LX.DBService.MusicInfo[]) => { // musicInfoOrderDeleteByListIdStatement.run(fromId) for (const id of ids) { musicInfoDeleteStatement.run({ listId: fromId, id }) musicInfoOrderDeleteStatement.run({ listId: fromId, id }) } for (const music of musicInfos) { musicInfoInsertStatement.run(music) musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } })(fromId, ids, musicInfos) } /** * 批量移动歌曲并刷新排序 * @param fromId 源列表Id * @param ids 要移动的歌曲id,原始选择的歌曲 * @param musicInfos 要移动的歌曲,目标列表去重后 * @param toListAll 目标列表歌曲 */ export const moveMusicInfoAndRefreshOrder = (fromId: string, ids: string[], toId: string, musicInfos: LX.DBService.MusicInfo[], toListAll: LX.DBService.MusicInfo[]) => { const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoDeleteStatement = createMusicInfoDeleteStatement() const musicInfoOrderDeleteStatement = createMusicInfoOrderDeleteStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() const db = getDB() db.transaction((fromId: string, ids: string[], musicInfos: LX.DBService.MusicInfo[], toListAll: LX.DBService.MusicInfo[]) => { for (const id of ids) { musicInfoDeleteStatement.run({ listId: fromId, id }) musicInfoOrderDeleteStatement.run({ listId: fromId, id }) } musicInfoOrderDeleteByListIdStatement.run(toId) for (const music of musicInfos) { musicInfoInsertStatement.run(music) musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } for (const music of toListAll) { musicInfoOrderInsertStatement.run({ listId: music.listId, musicInfoId: music.id, order: music.order, }) } })(fromId, ids, musicInfos, toListAll) } /** * 批量移除列表内音乐 * @param listId 列表id * @param ids 音乐id */ export const removeMusicInfos = (listId: string, ids: string[]) => { const musicInfoDeleteStatement = createMusicInfoDeleteStatement() const musicInfoOrderDeleteStatement = createMusicInfoOrderDeleteStatement() const db = getDB() db.transaction((listId: string, ids: string[]) => { for (const id of ids) { musicInfoDeleteStatement.run({ listId, id }) musicInfoOrderDeleteStatement.run({ listId, id }) } })(listId, ids) } /** * 清空列表内歌曲 * @param listId 列表id */ export const removeMusicInfoByListId = (ids: string[]) => { const db = getDB() const musicInfoDeleteByListIdStatement = createMusicInfoDeleteByListIdStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() db.transaction((ids: string[]) => { for (const id of ids) { musicInfoDeleteByListIdStatement.run(id) musicInfoOrderDeleteByListIdStatement.run(id) } })(ids) } /** * 创建根据列表Id与音乐id查询音乐信息 * @param listId 列表id * @param musicInfoId 音乐id * @returns */ export const queryMusicInfoByListIdAndMusicInfoId = (listId: string, musicInfoId: string) => { const musicInfoByListAndMusicInfoIdQueryStatement = createMusicInfoByListAndMusicInfoIdQueryStatement() return musicInfoByListAndMusicInfoIdQueryStatement.get({ listId, musicInfoId }) as LX.DBService.MusicInfo | null } /** * 创建根据音乐id查询所有列表的音乐信息 * @param id 音乐id * @returns */ export const queryMusicInfoByMusicInfoId = (id: string) => { const musicInfoByMusicInfoIdQueryStatement = createMusicInfoByMusicInfoIdQueryStatement() return musicInfoByMusicInfoIdQueryStatement.all(id) as LX.DBService.MusicInfo[] } /** * 批量更新歌曲位置 * @param listId 列表id * @param musicInfoOrders 音乐顺序 */ export const updateMusicInfoOrder = (listId: string, musicInfoOrders: LX.DBService.MusicInfoOrder[]) => { const db = getDB() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() db.transaction((listId: string, musicInfoOrders: LX.DBService.MusicInfoOrder[]) => { musicInfoOrderDeleteByListIdStatement.run(listId) for (const orderInfo of musicInfoOrders) musicInfoOrderInsertStatement.run(orderInfo) })(listId, musicInfoOrders) } /** * 覆盖列表内的歌曲 * @param listId 列表id * @param musicInfos 歌曲列表 */ export const overwriteMusicInfo = (listId: string, musicInfos: LX.DBService.MusicInfo[]) => { const db = getDB() const musicInfoDeleteByListIdStatement = createMusicInfoDeleteByListIdStatement() const musicInfoOrderDeleteByListIdStatement = createMusicInfoOrderDeleteByListIdStatement() const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() db.transaction((listId: string, musicInfos: LX.DBService.MusicInfo[]) => { musicInfoDeleteByListIdStatement.run(listId) musicInfoOrderDeleteByListIdStatement.run(listId) for (const musicInfo of musicInfos) { musicInfoInsertStatement.run(musicInfo) musicInfoOrderInsertStatement.run({ listId: musicInfo.listId, musicInfoId: musicInfo.id, order: musicInfo.order, }) } })(listId, musicInfos) } /** * 覆盖整个列表 * @param lists 列表 * @param musicInfos 歌曲列表 */ export const overwriteListData = (lists: LX.DBService.UserListInfo[], musicInfos: LX.DBService.MusicInfo[]) => { const db = getDB() const listClearStatement = createListClearStatement() const listInsertStatement = createListInsertStatement() const musicInfoClearStatement = createMusicInfoClearStatement() const musicInfoInsertStatement = createMusicInfoInsertStatement() const musicInfoOrderClearStatement = createMusicInfoOrderClearStatement() const musicInfoOrderInsertStatement = createMusicInfoOrderInsertStatement() db.transaction((lists: LX.DBService.UserListInfo[], musicInfos: LX.DBService.MusicInfo[]) => { listClearStatement.run() for (const list of lists) { listInsertStatement.run({ id: list.id, name: list.name, source: list.source, sourceListId: list.sourceListId, locationUpdateTime: list.locationUpdateTime, position: list.position, }) } musicInfoClearStatement.run() musicInfoOrderClearStatement.run() for (const musicInfo of musicInfos) { musicInfoInsertStatement.run(musicInfo) musicInfoOrderInsertStatement.run({ listId: musicInfo.listId, musicInfoId: musicInfo.id, order: musicInfo.order, }) } })(lists, musicInfos) } ================================================ FILE: src/main/worker/dbService/modules/list/index.ts ================================================ import { LIST_IDS } from '@common/constants' import { arrPush, arrPushByPosition, arrUnshift } from '@common/utils/common' import { deleteUserLists, insertUserLists, insertMusicInfoList, insertMusicInfoListAndRefreshOrder, moveMusicInfo, moveMusicInfoAndRefreshOrder, overwriteListData, overwriteMusicInfo, queryAllUserList, queryMusicInfoByListId, queryMusicInfoByListIdAndMusicInfoId, queryMusicInfoByMusicInfoId, removeMusicInfoByListId, removeMusicInfos, updateMusicInfoOrder, updateMusicInfos, updateUserLists as updateUserListsFromDB, } from './dbHelper' let userLists: LX.DBService.UserListInfo[] let musicLists = new Map() const toDBMusicInfo = (musicInfos: LX.Music.MusicInfo[], listId: string, offset: number = 0): LX.DBService.MusicInfo[] => { return musicInfos.map((info, index) => { return { ...info, listId, meta: JSON.stringify(info.meta), order: offset + index, } }) } /** * 获取所有用户列表 * @returns */ export const getAllUserList = (): LX.List.UserListInfo[] => { userLists ??= queryAllUserList() return userLists.map(list => { const { position, ...newList } = list return newList }) } /** * 批量创建列表 * @param position 列表位置 * @param lists 列表信息 */ export const createUserLists = (position: number, lists: LX.List.UserListInfo[]) => { userLists ??= queryAllUserList() if (position < 0 || position >= userLists.length) { const newLists: LX.DBService.UserListInfo[] = lists.map((list, index) => { return { ...list, position: position + index, } }) insertUserLists(newLists) userLists = [...userLists, ...newLists] } else { const newUserLists = [...userLists] // @ts-expect-error newUserLists.splice(position, 0, ...lists) newUserLists.forEach((list, index) => { list.position = index }) insertUserLists(newUserLists, true) userLists = newUserLists } } /** * 覆盖列表 * @param lists 列表信息 */ // const setUserLists = (lists: LX.List.UserListInfo[]) => { // const newUserLists: LX.DBService.UserListInfo[] = lists.map((list, index) => { // return { // ...list, // position: index, // } // }) // insertUserLists(newUserLists, true) // userLists = newUserLists // } /** * 批量删除列表 * @param ids 列表ids */ export const removeUserLists = (ids: string[]) => { deleteUserLists(ids) userLists &&= queryAllUserList() } /** * 批量更新列表信息 * @param lists 列表信息 */ export const updateUserLists = (lists: LX.List.UserListInfo[]) => { const positionMap = new Map() for (const list of userLists) { positionMap.set(list.id, list.position) } const dbList: LX.DBService.UserListInfo[] = lists.map(list => { const position = positionMap.get(list.id) if (position == null) return null return { ...list, position, } }).filter(Boolean) as LX.DBService.UserListInfo[] updateUserListsFromDB(dbList) userLists &&= queryAllUserList() } /** * 批量更新列表位置 * @param position 列表位置 * @param ids 列表ids */ export const updateUserListsPosition = (position: number, ids: string[]) => { userLists ??= queryAllUserList() const newUserLists = [...userLists] const updateLists: LX.DBService.UserListInfo[] = [] for (let i = newUserLists.length - 1; i >= 0; i--) { if (ids.includes(newUserLists[i].id)) { const list = newUserLists.splice(i, 1)[0] list.locationUpdateTime = Date.now() updateLists.push(list) } } position = Math.min(newUserLists.length, position) newUserLists.splice(position, 0, ...updateLists) newUserLists.forEach((list, index) => { list.position = index }) insertUserLists(newUserLists, true) userLists = newUserLists } /** * 根据列表ID获取列表内歌曲 * @param listId 列表ID * @returns 列表内歌曲 */ export const getListMusics = (listId: string): LX.Music.MusicInfo[] => { let targetList: LX.Music.MusicInfo[] | undefined = musicLists.get(listId) if (targetList == null) { targetList = queryMusicInfoByListId(listId).map(info => { return { id: info.id, name: info.name, singer: info.singer, source: info.source, interval: info.interval, meta: JSON.parse(info.meta), } }) musicLists.set(listId, targetList) } return targetList } /** * 覆盖列表内的歌曲 * @param listId 列表id * @param musicInfos 歌曲列表 */ export const musicOverwrite = (listId: string, musicInfos: LX.Music.MusicInfo[]) => { let targetList = getListMusics(listId) overwriteMusicInfo(listId, toDBMusicInfo(musicInfos, listId)) if (targetList) { targetList.splice(0, targetList.length) arrPush(targetList, musicInfos) } } /** * 批量添加歌曲 * @param listId 列表id * @param musicInfos 添加的歌曲信息 * @param addMusicLocationType 添加在到列表的位置 */ export const musicsAdd = (listId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType) => { let targetList = getListMusics(listId) const set = new Set() for (const item of targetList) set.add(item.id) musicInfos = musicInfos.filter(item => { if (set.has(item.id)) return false set.add(item.id) return true }) switch (addMusicLocationType) { case 'top': insertMusicInfoListAndRefreshOrder(toDBMusicInfo(musicInfos, listId), listId, toDBMusicInfo(targetList, listId, musicInfos.length)) arrUnshift(targetList, musicInfos) break case 'bottom': default: insertMusicInfoList(toDBMusicInfo(musicInfos, listId, targetList.length)) arrPush(targetList, musicInfos) break } } /** * 批量删除歌曲 * @param listId 列表Id * @param ids 要删除歌曲的id */ export const musicsRemove = (listId: string, ids: string[]) => { let targetList = getListMusics(listId) if (!targetList.length) return removeMusicInfos(listId, ids) const idsSet = new Set(ids) musicLists.set(listId, targetList.filter(mInfo => !idsSet.has(mInfo.id))) } /** * 批量移动歌曲 * @param fromId 源列表id * @param toId 目标列表id * @param musicInfos 添加的歌曲信息 * @param addMusicLocationType 添加在到列表的位置 */ export const musicsMove = (fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType) => { let fromList = getListMusics(fromId) let toList = getListMusics(toId) const ids = musicInfos.map(musicInfo => musicInfo.id) let listSet = new Set() for (const item of toList) listSet.add(item.id) musicInfos = musicInfos.filter(item => { if (listSet.has(item.id)) return false listSet.add(item.id) return true }) switch (addMusicLocationType) { case 'top': moveMusicInfoAndRefreshOrder(fromId, ids, toId, toDBMusicInfo(musicInfos, toId), toDBMusicInfo(toList, toId, musicInfos.length)) arrUnshift(toList, musicInfos) break case 'bottom': default: moveMusicInfo(fromId, ids, toDBMusicInfo(musicInfos, toId, toList.length)) arrPush(toList, musicInfos) break } listSet = new Set(ids) musicLists.set(fromId, fromList.filter(mInfo => !listSet.has(mInfo.id))) } /** * 批量更新歌曲信息 * @param musicInfos 歌曲&列表信息 */ export const musicsUpdate = (musicInfos: LX.List.ListActionMusicUpdate) => { updateMusicInfos(musicInfos.map(({ id, musicInfo }) => { return { ...musicInfo, listId: id, meta: JSON.stringify(musicInfo.meta), order: 0, } })) for (const { id, musicInfo } of musicInfos) { const targetList = musicLists.get(id) if (targetList == null) continue const targetMusic = targetList.find(item => item.id == musicInfo.id) if (!targetMusic) continue targetMusic.name = musicInfo.name targetMusic.singer = musicInfo.singer targetMusic.source = musicInfo.source targetMusic.interval = musicInfo.interval targetMusic.meta = musicInfo.meta } } /** * 清空列表内的歌曲 * @param listId 列表Id */ export const musicsClear = (ids: string[]) => { removeMusicInfoByListId(ids) for (const id of ids) { const targetList = musicLists.get(id) if (!targetList) continue targetList.splice(0, targetList.length) } } /** * 批量更新歌曲位置 * @param listId 列表id * @param position 新位置 * @param ids 要更新位置的歌曲id */ export const musicsPositionUpdate = (listId: string, position: number, ids: string[]) => { let targetList = getListMusics(listId) if (!targetList.length) return let newTargetList = [...targetList] const infos: LX.Music.MusicInfo[] = [] const map = new Map() for (const item of newTargetList) map.set(item.id, item) for (const id of ids) { infos.push(map.get(id)!) map.delete(id) } newTargetList = newTargetList.filter(mInfo => map.has(mInfo.id)) arrPushByPosition(newTargetList, infos, Math.min(position, newTargetList.length)) updateMusicInfoOrder(listId, newTargetList.map((info, index) => { return { listId, musicInfoId: info.id, order: index, } })) musicLists.set(listId, newTargetList) } /** * 覆盖所有列表数据 * @param myListData 完整列表数据 */ export const listDataOverwrite = (myListData: MakeOptional) => { const dbLists: LX.DBService.UserListInfo[] = [] const listData: LX.List.ListDataFull = { ...myListData, tempList: myListData.tempList ?? getListMusics(LIST_IDS.TEMP), } const dbMusicInfos: LX.DBService.MusicInfo[] = [ ...toDBMusicInfo(listData.defaultList, LIST_IDS.DEFAULT), ...toDBMusicInfo(listData.loveList, LIST_IDS.LOVE), ...toDBMusicInfo(listData.tempList, LIST_IDS.TEMP), ] listData.userList.forEach(({ list, ...listInfo }, index) => { dbLists.push({ ...listInfo, position: index }) arrPush(dbMusicInfos, toDBMusicInfo(list, listInfo.id)) }) overwriteListData(dbLists, dbMusicInfos) if (userLists) userLists.splice(0, userLists.length, ...dbLists) else userLists = dbLists musicLists.clear() musicLists.set(LIST_IDS.DEFAULT, listData.defaultList) musicLists.set(LIST_IDS.LOVE, listData.loveList) musicLists.set(LIST_IDS.TEMP, listData.tempList) for (const list of listData.userList) musicLists.set(list.id, list.list) } /** * 检查音乐是否存在列表中 * @param listId 列表id * @param musicInfoId 音乐id * @returns */ export const checkListExistMusic = (listId: string, musicInfoId: string): boolean => { const musicInfo = queryMusicInfoByListIdAndMusicInfoId(listId, musicInfoId) return musicInfo != null } /** * 获取所有存在该音乐的列表id * @param musicInfoId 音乐id * @returns */ export const getMusicExistListIds = (musicInfoId: string): string[] => { const musicInfos = queryMusicInfoByMusicInfoId(musicInfoId) return musicInfos.map(m => m.listId) } ================================================ FILE: src/main/worker/dbService/modules/list/statements.ts ================================================ import { getDB } from '../../db' /** * 创建列表查询语句 * @returns 查询语句 */ export const createListQueryStatement = () => { const db = getDB() return db.prepare<[]>(` SELECT "id", "name", "source", "sourceListId", "position", "locationUpdateTime" FROM "main"."my_list" `) } /** * 创建列表插入语句 * @returns 插入语句 */ export const createListInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.UserListInfo]>(` INSERT INTO "main"."my_list" ("id", "name", "source", "sourceListId", "position", "locationUpdateTime") VALUES (@id, @name, @source, @sourceListId, @position, @locationUpdateTime)`) } /** * 创建列表清空语句 * @returns 清空语句 */ export const createListClearStatement = () => { const db = getDB() return db.prepare<[]>('DELETE FROM "main"."my_list"') } /** * 创建列表删除语句 * @returns 删除语句 */ export const createListDeleteStatement = () => { const db = getDB() return db.prepare<[string]>('DELETE FROM "main"."my_list" WHERE "id"=?') } /** * 创建列表更新语句 * @returns 更新语句 */ export const createListUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.UserListInfo]>(` UPDATE "main"."my_list" SET "name"=@name, "source"=@source, "sourceListId"=@sourceListId, "locationUpdateTime"=@locationUpdateTime WHERE "id"=@id`) } /** * 创建音乐信息查询语句 * @returns 查询语句 */ export const createMusicInfoQueryStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfoQuery]>(` SELECT mInfo."id", mInfo."name", mInfo."singer", mInfo."source", mInfo."interval", mInfo."meta" FROM my_list_music_info mInfo LEFT JOIN my_list_music_info_order O ON mInfo.id=O.musicInfoId AND O.listId=@listId WHERE mInfo.listId=@listId ORDER BY O."order" ASC `) } /** * 创建音乐信息插入语句 * @returns 插入语句 */ export const createMusicInfoInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfo]>(` INSERT INTO "main"."my_list_music_info" ("id", "listId", "name", "singer", "source", "interval", "meta") VALUES (@id, @listId, @name, @singer, @source, @interval, @meta)`) } /** * 创建音乐信息更新语句 * @returns 更新语句 */ export const createMusicInfoUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfo]>(` UPDATE "main"."my_list_music_info" SET "name"=@name, "singer"=@singer, "source"=@source, "interval"=@interval, "meta"=@meta WHERE "id"=@id AND "listId"=@listId`) } /** * 创建清空音乐信息语句 * @returns 删除语句 */ export const createMusicInfoClearStatement = () => { const db = getDB() return db.prepare<[]>('DELETE FROM "main"."my_list_music_info"') } /** * 创建根据列表id批量删除音乐信息语句 * @returns 删除语句 */ export const createMusicInfoDeleteByListIdStatement = () => { const db = getDB() return db.prepare<[string]>('DELETE FROM "main"."my_list_music_info" WHERE "listId"=?') } /** * 创建根据列表Id与音乐id批量删除音乐信息语句 * @returns 删除语句 */ export const createMusicInfoDeleteStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfoRemove]>('DELETE FROM "main"."my_list_music_info" WHERE "id"=@id AND "listId"=@listId') } /** * 创建根据列表Id与音乐id查询音乐信息语句 * @returns 删除语句 */ export const createMusicInfoByListAndMusicInfoIdQueryStatement = () => { const db = getDB() return db.prepare<[LX.DBService.ListMusicInfoQuery]>(`SELECT "id", "name", "singer", "source", "interval", "meta" FROM "main"."my_list_music_info" WHERE "id"=@musicInfoId AND "listId"=@listId`) } /** * 创建根据音乐id查询音乐信息语句 * @returns 删除语句 */ export const createMusicInfoByMusicInfoIdQueryStatement = () => { const db = getDB() return db.prepare<[string]>(`SELECT "id", "name", "singer", "source", "interval", "meta", "listId" FROM "main"."my_list_music_info" WHERE "id"=?`) } /** * 创建音乐信息排序插入语句 * @returns 插入语句 */ export const createMusicInfoOrderInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfoOrder]>(` INSERT INTO "main"."my_list_music_info_order" ("listId", "musicInfoId", "order") VALUES (@listId, @musicInfoId, @order)`) } /** * 创建清空音乐排序语句 * @returns 删除语句 */ export const createMusicInfoOrderClearStatement = () => { const db = getDB() return db.prepare<[]>('DELETE FROM "main"."my_list_music_info_order"') } /** * 创建根据列表id删除音乐排序语句 * @returns 删除语句 */ export const createMusicInfoOrderDeleteByListIdStatement = () => { const db = getDB() return db.prepare<[string]>('DELETE FROM "main"."my_list_music_info_order" WHERE "listId"=?') } /** * 创建根据列表Id与音乐id删除音乐排序语句 * @returns 删除语句 */ export const createMusicInfoOrderDeleteStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfoRemove]>('DELETE FROM "main"."my_list_music_info_order" WHERE "musicInfoId"=@id AND "listId"=@listId') } ================================================ FILE: src/main/worker/dbService/modules/lyric/dbHelper.ts ================================================ import { getDB } from '../../db' import { createLyricQueryStatement, createRawLyricQueryStatement, createRawLyricInsertStatement, createRawLyricDeleteStatement, createRawLyricUpdateStatement, createRawLyricClearStatement, createEditedLyricQueryStatement, createEditedLyricInsertStatement, createEditedLyricDeleteStatement, createEditedLyricUpdateStatement, createEditedLyricClearStatement, createEditedLyricCountStatement, createRawLyricCountStatement, } from './statements' /** * 查询原始歌词 * @param id 歌曲id * @returns 歌词信息 */ export const queryLyric = (id: string) => { const lyricQueryStatement = createLyricQueryStatement() return lyricQueryStatement.all(id) as LX.DBService.Lyricnfo[] } /** * 查询原始歌词 * @param id 歌曲id * @returns 歌词信息 */ export const queryRawLyric = (id: string) => { const rawLyricQueryStatement = createRawLyricQueryStatement() return rawLyricQueryStatement.all(id) as LX.DBService.Lyricnfo[] } /** * 批量插入原始歌词 * @param lyrics 列表 */ export const insertRawLyric = (lyrics: LX.DBService.Lyricnfo[]) => { const db = getDB() const rawLyricInsertStatement = createRawLyricInsertStatement() db.transaction((lyrics: LX.DBService.Lyricnfo[]) => { for (const lyric of lyrics) rawLyricInsertStatement.run(lyric) })(lyrics) } /** * 批量删除原始歌词 * @param ids 列表 */ export const deleteRawLyric = (ids: string[]) => { const db = getDB() const rawLyricDeleteStatement = createRawLyricDeleteStatement() db.transaction((ids: string[]) => { for (const id of ids) rawLyricDeleteStatement.run(id) })(ids) } /** * 批量更新原始歌词 * @param lyrics 列表 */ export const updateRawLyric = (lyrics: LX.DBService.Lyricnfo[]) => { const db = getDB() const rawLyricUpdateStatement = createRawLyricUpdateStatement() db.transaction((lyrics: LX.DBService.Lyricnfo[]) => { for (const lyric of lyrics) rawLyricUpdateStatement.run(lyric) })(lyrics) } /** * 清空原始歌词 */ export const clearRawLyric = () => { const rawLyricClearStatement = createRawLyricClearStatement() rawLyricClearStatement.run() } /** * 统计已编辑歌词数量 */ export const countRawLyric = () => { const countStatement = createRawLyricCountStatement() return (countStatement.get() as { count: number }).count } /** * 查询已编辑歌词 * @param id 歌曲id * @returns 歌词信息 */ export const queryEditedLyric = (id: string) => { const rawLyricQueryStatement = createEditedLyricQueryStatement() return rawLyricQueryStatement.all(id) as LX.DBService.Lyricnfo[] } /** * 批量插入已编辑歌词 * @param lyrics 列表 */ export const insertEditedLyric = (lyrics: LX.DBService.Lyricnfo[]) => { const db = getDB() const rawLyricInsertStatement = createEditedLyricInsertStatement() db.transaction((lyrics: LX.DBService.Lyricnfo[]) => { for (const lyric of lyrics) rawLyricInsertStatement.run(lyric) })(lyrics) } /** * 批量删除已编辑歌词 * @param ids 列表 */ export const deleteEditedLyric = (ids: string[]) => { const db = getDB() const rawLyricDeleteStatement = createEditedLyricDeleteStatement() db.transaction((ids: string[]) => { for (const id of ids) rawLyricDeleteStatement.run(id) })(ids) } /** * 批量更新已编辑歌词 * @param lyrics 列表 */ export const updateEditedLyric = (lyrics: LX.DBService.Lyricnfo[]) => { const db = getDB() const rawLyricUpdateStatement = createEditedLyricUpdateStatement() db.transaction((lyrics: LX.DBService.Lyricnfo[]) => { for (const lyric of lyrics) rawLyricUpdateStatement.run(lyric) })(lyrics) } /** * 清空已编辑歌词 */ export const clearEditedLyric = () => { const rawLyricClearStatement = createEditedLyricClearStatement() rawLyricClearStatement.run() } /** * 统计已编辑歌词数量 */ export const countEditedLyric = () => { const countStatement = createEditedLyricCountStatement() return (countStatement.get() as { count: number }).count } ================================================ FILE: src/main/worker/dbService/modules/lyric/index.ts ================================================ import { queryLyric, queryRawLyric, insertRawLyric, deleteRawLyric, updateRawLyric, clearRawLyric, queryEditedLyric, insertEditedLyric, deleteEditedLyric, updateEditedLyric, clearEditedLyric, countEditedLyric, countRawLyric, } from './dbHelper' const keys = ['lyric', 'tlyric', 'rlyric', 'lxlyric'] as const const toDBLyric = (id: string, source: LX.DBService.Lyricnfo['source'], lyricInfo: LX.Music.LyricInfo): LX.DBService.Lyricnfo[] => { return (keys.map(k => [k, lyricInfo[k]]) .filter(([k, t]) => t != null) as Array<[LX.DBService.Lyricnfo['type'], string]>) .map(([k, t]) => { return { id, type: k, text: Buffer.from(t).toString('base64'), source, } }) } /** * 获取歌词 * @param id 歌曲id * @returns 歌词信息 */ export const getPlayerLyric = (id: string): LX.Player.LyricInfo => { const lyrics = queryLyric(id) let lyricInfo: LX.Music.LyricInfo = { lyric: '', } let rawLyricInfo: LX.Music.LyricInfo = { lyric: '', } for (const lyric of lyrics) { switch (lyric.source) { case 'edited': if (lyric.type == 'lyric') lyricInfo.lyric = Buffer.from(lyric.text, 'base64').toString() else if (lyric.text != null) lyricInfo[lyric.type] = Buffer.from(lyric.text, 'base64').toString() break default: if (lyric.type == 'lyric') rawLyricInfo.lyric = Buffer.from(lyric.text, 'base64').toString() else if (lyric.text != null) rawLyricInfo[lyric.type] = Buffer.from(lyric.text, 'base64').toString() break } } return lyricInfo.lyric ? { ...lyricInfo, rawlrcInfo: rawLyricInfo, } : { ...rawLyricInfo, rawlrcInfo: rawLyricInfo, } } /** * 获取原始歌词 * @param id 歌曲id * @returns 歌词信息 */ export const getRawLyric = (id: string): LX.Music.LyricInfo => { const lyrics = queryRawLyric(id) let lyricInfo: LX.Music.LyricInfo = { lyric: '', } for (const lyric of lyrics) { if (lyric.type == 'lyric') lyricInfo.lyric = Buffer.from(lyric.text, 'base64').toString() else if (lyric.text != null) lyricInfo[lyric.type] = Buffer.from(lyric.text, 'base64').toString() } return lyricInfo } /** * 保存原始歌词信息 * @param id 歌曲id * @param lyricInfo 歌词信息 */ export const rawLyricAdd = (id: string, lyricInfo: LX.Music.LyricInfo) => { insertRawLyric(toDBLyric(id, 'raw', lyricInfo)) } /** * 删除原始歌词信息 * @param ids 歌曲id */ export const rawLyricRemove = (ids: string[]) => { deleteRawLyric(ids) } /** * 更新原始歌词信息 * @param id 歌曲id * @param lyricInfo 歌词信息 */ export const rawLyricUpdate = (id: string, lyricInfo: LX.Music.LyricInfo) => { updateRawLyric(toDBLyric(id, 'raw', lyricInfo)) } /** * 清空原始歌词信息 */ export const rawLyricClear = () => { clearRawLyric() } /** * 统计原始歌词数量 */ export const rawLyricCount = () => { return countRawLyric() } /** * 获取已编辑歌词 * @param id 歌曲id * @returns 歌词信息 */ export const getEditedLyric = (id: string): LX.Music.LyricInfo => { const lyrics = queryEditedLyric(id) let lyricInfo: LX.Music.LyricInfo = { lyric: '', } for (const lyric of lyrics) { if (lyric.type == 'lyric') lyricInfo.lyric = Buffer.from(lyric.text, 'base64').toString() else if (lyric.text != null) lyricInfo[lyric.type] = Buffer.from(lyric.text, 'base64').toString() } return lyricInfo } /** * 保存已编辑歌词信息 * @param id 歌曲id * @param lyricInfo 歌词信息 */ export const editedLyricAdd = (id: string, lyricInfo: LX.Music.LyricInfo) => { insertEditedLyric(toDBLyric(id, 'edited', lyricInfo)) } /** * 删除已编辑歌词信息 * @param ids 歌曲id */ export const editedLyricRemove = (ids: string[]) => { deleteEditedLyric(ids) } /** * 更新已编辑歌词信息 * @param id 歌曲id * @param lyricInfo 歌词信息 */ export const editedLyricUpdate = (id: string, lyricInfo: LX.Music.LyricInfo) => { updateEditedLyric(toDBLyric(id, 'edited', lyricInfo)) } /** * 清空已编辑歌词信息 */ export const editedLyricClear = () => { clearEditedLyric() } /** * 新增或更新已编辑歌词信息 * @param id 歌曲id * @param lyricInfo 歌词信息 */ export const editedLyricUpdateAddAndUpdate = (id: string, lyricInfo: LX.Music.LyricInfo) => { const lyrics = queryEditedLyric(id) if (lyrics.length) updateEditedLyric(toDBLyric(id, 'edited', lyricInfo)) else insertEditedLyric(toDBLyric(id, 'edited', lyricInfo)) } /** * 统计已编辑歌词数量 */ export const editedLyricCount = () => { return countEditedLyric() } ================================================ FILE: src/main/worker/dbService/modules/lyric/statements.ts ================================================ import { getDB } from '../../db' const RAW_LYRIC = 'raw' const EDITED_LYRIC = 'edited' /** * 创建歌词查询语句 * @returns 查询语句 */ export const createLyricQueryStatement = () => { const db = getDB() return db.prepare<[string]>(` SELECT "type", "text", "source" FROM "main"."lyric" WHERE "id"=? `) } /** * 创建原始歌词查询语句 * @returns 查询语句 */ export const createRawLyricQueryStatement = () => { const db = getDB() return db.prepare<[string]>(` SELECT "type", "text" FROM "main"."lyric" WHERE "id"=? AND "source"='${RAW_LYRIC}' `) } /** * 创建原始歌词插入语句 * @returns 插入语句 */ export const createRawLyricInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.Lyricnfo]>(` INSERT INTO "main"."lyric" ("id", "type", "text", "source") VALUES (@id, @type, @text, '${RAW_LYRIC}')`) } /** * 创建原始歌词清空语句 * @returns 清空语句 */ export const createRawLyricClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."lyric" WHERE "source"='${RAW_LYRIC}' `) } /** * 创建原始歌词删除语句 * @returns 删除语句 */ export const createRawLyricDeleteStatement = () => { const db = getDB() return db.prepare<[string]>(` DELETE FROM "main"."lyric" WHERE "id"=? AND "source"='${RAW_LYRIC}' `) } /** * 创建原始歌词更新语句 * @returns 更新语句 */ export const createRawLyricUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.Lyricnfo]>(` UPDATE "main"."lyric" SET "text"=@text WHERE "id"=@id AND "source"='${RAW_LYRIC}' AND "type"=@type`) } /** * 创建原始歌词数量统计语句 * @returns 统计语句 */ export const createRawLyricCountStatement = () => { const db = getDB() return db.prepare<[]>(`SELECT COUNT(*) as count FROM "main"."lyric" WHERE "source"='${RAW_LYRIC}'`) } /** * 创建已编辑歌词查询语句 * @returns 查询语句 */ export const createEditedLyricQueryStatement = () => { const db = getDB() return db.prepare<[string]>(` SELECT "type", "text" FROM "main"."lyric" WHERE "id"=? AND "source"='${EDITED_LYRIC}' `) } /** * 创建已编辑歌词插入语句 * @returns 插入语句 */ export const createEditedLyricInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.Lyricnfo]>(` INSERT INTO "main"."lyric" ("id", "type", "text", "source") VALUES (@id, @type, @text, '${EDITED_LYRIC}')`) } /** * 创建已编辑歌词清空语句 * @returns 清空语句 */ export const createEditedLyricClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."lyric" WHERE "source"='${EDITED_LYRIC}' `) } /** * 创建已编辑歌词删除语句 * @returns 删除语句 */ export const createEditedLyricDeleteStatement = () => { const db = getDB() return db.prepare<[string]>(` DELETE FROM "main"."lyric" WHERE "id"=? AND "source"='${EDITED_LYRIC}' `) } /** * 创建已编辑歌词更新语句 * @returns 更新语句 */ export const createEditedLyricUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.Lyricnfo]>(` UPDATE "main"."lyric" SET "text"=@text WHERE "id"=@id AND "source"='${EDITED_LYRIC}' AND "type"=@type`) } /** * 创建已编辑歌词数量统计语句 * @returns 统计语句 */ export const createEditedLyricCountStatement = () => { const db = getDB() return db.prepare<[]>(`SELECT COUNT(*) as count FROM "main"."lyric" WHERE "source"='${EDITED_LYRIC}'`) } ================================================ FILE: src/main/worker/dbService/modules/music_other_source/dbHelper.ts ================================================ import { getDB } from '../../db' import { createMusicInfoQueryStatement, createMusicInfoInsertStatement, createMusicInfoDeleteStatement, createMusicInfoClearStatement, createCountStatement, } from './statements' /** * 查询歌曲信息 * @param id 歌曲id * @returns 歌曲信息 */ export const queryMusicInfo = (id: string) => { const musicInfoQueryStatement = createMusicInfoQueryStatement() return musicInfoQueryStatement.all(id) as LX.DBService.MusicInfoOtherSource[] } /** * 批量插入歌曲信息 * @param musicInfos 列表 */ export const insertMusicInfo = (musicInfos: LX.DBService.MusicInfoOtherSource[]) => { const db = getDB() const musicInfoInsertStatement = createMusicInfoInsertStatement() db.transaction((musicInfos: LX.DBService.MusicInfoOtherSource[]) => { for (const info of musicInfos) musicInfoInsertStatement.run(info) })(musicInfos) } /** * 批量删除歌曲信息 * @param ids 列表 */ export const deleteMusicInfo = (ids: string[]) => { const db = getDB() const musicInfoDeleteStatement = createMusicInfoDeleteStatement() db.transaction((ids: string[]) => { for (const id of ids) musicInfoDeleteStatement.run(id) })(ids) } /** * 清空歌曲信息 */ export const clearMusicInfo = () => { const musicInfoClearStatement = createMusicInfoClearStatement() musicInfoClearStatement.run() } /** * 统计歌曲信息数量 */ export const countMusicInfo = () => { const countStatement = createCountStatement() return (countStatement.get() as { count: number }).count } ================================================ FILE: src/main/worker/dbService/modules/music_other_source/index.ts ================================================ import { queryMusicInfo, insertMusicInfo, deleteMusicInfo, clearMusicInfo, countMusicInfo, } from './dbHelper' const toDBMusicInfo = (id: string, musicInfos: LX.Music.MusicInfo[]): LX.DBService.MusicInfoOtherSource[] => { return musicInfos.map((info, index) => { return { ...info, meta: JSON.stringify(info.meta), source_id: id, order: index, } }) } /** * 获取歌曲信息 * @param id 歌曲id * @returns 歌词信息 */ export const getMusicInfoOtherSource = (id: string): LX.Music.MusicInfoOnline[] => { const list = queryMusicInfo(id).sort((a, b) => a.order - b.order).map(info => { return { id: info.id, name: info.name, singer: info.singer, source: info.source, interval: info.interval, meta: JSON.parse(info.meta), } }) return list } /** * 保存歌曲信息信息 * @param id 歌曲id * @param musicInfos 歌词信息 */ export const musicInfoOtherSourceAdd = (id: string, musicInfos: LX.Music.MusicInfoOnline[]) => { insertMusicInfo(toDBMusicInfo(id, musicInfos)) } /** * 删除歌曲信息信息 * @param ids 歌曲id */ export const musicInfoOtherSourceRemove = (ids: string[]) => { deleteMusicInfo(ids) } /** * 清空歌曲信息信息 */ export const musicInfoOtherSourceClear = () => { clearMusicInfo() } /** * 统计歌曲信息信息数量 */ export const musicInfoOtherSourceCount = () => { return countMusicInfo() } ================================================ FILE: src/main/worker/dbService/modules/music_other_source/statements.ts ================================================ import { getDB } from '../../db' /** * 创建歌曲信息查询语句 * @returns 查询语句 */ export const createMusicInfoQueryStatement = () => { const db = getDB() return db.prepare<[string]>(` SELECT "id", "name", "singer", "source", "meta" FROM "main"."music_info_other_source" WHERE source_id=? ORDER BY "order" ASC `) } /** * 创建歌曲信息插入语句 * @returns 插入语句 */ export const createMusicInfoInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicInfoOtherSource]>(` INSERT INTO "main"."music_info_other_source" ("id", "name", "singer", "source", "meta", "source_id", "order") VALUES (@id, @name, @singer, @source, @meta, @source_id, @order) `) } /** * 创建歌曲信息清空语句 * @returns 清空语句 */ export const createMusicInfoClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."music_info_other_source" `) } /** * 创建歌曲信息删除语句 * @returns 删除语句 */ export const createMusicInfoDeleteStatement = () => { const db = getDB() return db.prepare<[string]>(` DELETE FROM "main"."music_info_other_source" WHERE "source_id"=? `) } /** * 创建数量统计语句 * @returns 统计语句 */ export const createCountStatement = () => { const db = getDB() return db.prepare<[]>('SELECT COUNT(*) as count FROM "main"."music_info_other_source"') } ================================================ FILE: src/main/worker/dbService/modules/music_url/dbHelper.ts ================================================ import { getDB } from '../../db' import { createQueryStatement, createInsertStatement, createDeleteStatement, // createUpdateStatement, createClearStatement, createCountStatement, } from './statements' /** * 查询歌曲url * @param id 歌曲id * @returns url */ export const queryMusicUrl = (id: string) => { const queryStatement = createQueryStatement() return (queryStatement.get(id) as { url: string } | null)?.url ?? null } /** * 批量插入歌曲url * @param urlInfo 列表 */ export const insertMusicUrl = (urlInfo: LX.DBService.MusicUrlInfo[]) => { const db = getDB() const insertStatement = createInsertStatement() const deleteStatement = createDeleteStatement() db.transaction((urlInfo: LX.DBService.MusicUrlInfo[]) => { for (const info of urlInfo) { deleteStatement.run(info.id) insertStatement.run(info) } })(urlInfo) } /** * 批量删除歌曲url * @param ids 列表 */ export const deleteMusicUrl = (ids: string[]) => { const db = getDB() const deleteStatement = createDeleteStatement() db.transaction((ids: string[]) => { for (const id of ids) deleteStatement.run(id) })(ids) } /** * 批量更新歌曲url * @param urlInfo 列表 */ // export const updateMusicUrl = (urlInfo: LX.DBService.MusicUrlInfo[]) => { // const db = getDB() // const updateStatement = createUpdateStatement() // db.transaction((urlInfo: LX.DBService.MusicUrlInfo[]) => { // for (const info of urlInfo) updateStatement.run(info) // })(urlInfo) // } /** * 清空歌曲url */ export const clearMusicUrl = () => { const clearStatement = createClearStatement() clearStatement.run() } /** * 统计歌曲信息数量 */ export const countMusicUrl = () => { const countStatement = createCountStatement() return (countStatement.get() as { count: number }).count } ================================================ FILE: src/main/worker/dbService/modules/music_url/index.ts ================================================ import { queryMusicUrl, insertMusicUrl, deleteMusicUrl, clearMusicUrl, countMusicUrl, } from './dbHelper' /** * 获取歌曲url * @param id 歌曲id * @returns 歌曲url */ export const getMusicUrl = (id: string): string | null => { const url = queryMusicUrl(id) return url } /** * 保存歌曲url * @param urlInfos url信息 */ export const musicUrlSave = (urlInfos: LX.Music.MusicUrlInfo[]) => { insertMusicUrl(urlInfos) } /** * 删除歌曲url * @param ids 歌曲id */ export const musicUrlRemove = (ids: string[]) => { deleteMusicUrl(ids) } /** * 清空歌曲url */ export const musicUrlClear = () => { clearMusicUrl() } /** * 统计歌曲url数量 */ export const musicUrlCount = () => { return countMusicUrl() } ================================================ FILE: src/main/worker/dbService/modules/music_url/statements.ts ================================================ import { getDB } from '../../db' /** * 创建歌曲url查询语句 * @returns 查询语句 */ export const createQueryStatement = () => { const db = getDB() return db.prepare<[string]>(` SELECT "url" FROM "main"."music_url" WHERE "id"=? `) } /** * 创建歌曲url插入语句 * @returns 插入语句 */ export const createInsertStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicUrlInfo]>(` INSERT INTO "main"."music_url" ("id", "url") VALUES (@id, @url)`) } /** * 创建歌曲url清空语句 * @returns 清空语句 */ export const createClearStatement = () => { const db = getDB() return db.prepare<[]>(` DELETE FROM "main"."music_url" `) } /** * 创建歌曲url删除语句 * @returns 删除语句 */ export const createDeleteStatement = () => { const db = getDB() return db.prepare<[string]>(` DELETE FROM "main"."music_url" WHERE "id"=? `) } /** * 创建歌曲url更新语句 * @returns 更新语句 */ export const createUpdateStatement = () => { const db = getDB() return db.prepare<[LX.DBService.MusicUrlInfo]>(` UPDATE "main"."music_url" SET "url"=@url WHERE "id"=@id`) } /** * 创建数量统计语句 * @returns 统计语句 */ export const createCountStatement = () => { const db = getDB() return db.prepare<[]>('SELECT COUNT(*) as count FROM "main"."music_url"') } ================================================ FILE: src/main/worker/dbService/tables.ts ================================================ // export const sql = ` // CREATE TABLE "db_info" ( // "id" INTEGER NOT NULL UNIQUE, // "field_name" TEXT, // "field_value" TEXT, // PRIMARY KEY("id" AUTOINCREMENT) // ); // CREATE TABLE "my_list" ( // "id" TEXT NOT NULL, // "name" TEXT NOT NULL, // "source" TEXT, // "sourceListId" TEXT, // "position" INTEGER NOT NULL, // "locationUpdateTime" INTEGER, // PRIMARY KEY("id") // ); // CREATE TABLE "my_list_music_info" ( // "id" TEXT NOT NULL, // "listId" TEXT NOT NULL, // "name" TEXT NOT NULL, // "singer" TEXT NOT NULL, // "source" TEXT NOT NULL, // "interval" TEXT, // "meta" TEXT NOT NULL, // UNIQUE("id","listId") // ); // CREATE INDEX "index_my_list_music_info" ON "my_list_music_info" ( // "id", // "listId" // ); // CREATE TABLE "my_list_music_info_order" ( // "listId" TEXT NOT NULL, // "musicInfoId" TEXT NOT NULL, // "order" INTEGER NOT NULL // ); // CREATE INDEX "index_my_list_music_info_order" ON "my_list_music_info_order" ( // "listId", // "musicInfoId" // ); // CREATE TABLE "music_info_other_source" ( // "source_id" TEXT NOT NULL, // "id" TEXT NOT NULL, // "source" TEXT NOT NULL, // "name" TEXT NOT NULL, // "singer" TEXT NOT NULL, // "meta" TEXT NOT NULL, // "order" INTEGER NOT NULL, // UNIQUE("source_id","id") // ); // CREATE INDEX "index_music_info_other_source" ON "music_info_other_source" ( // "source_id", // "id" // ); // -- TODO "meta" TEXT NOT NULL, // CREATE TABLE "lyric" ( // "id" TEXT NOT NULL, // "source" TEXT NOT NULL, // "type" TEXT NOT NULL, // "text" TEXT NOT NULL // ); // CREATE TABLE "music_url" ( // "id" TEXT NOT NULL, // "url" TEXT NOT NULL // ); // CREATE TABLE "download_list" ( // "id" TEXT NOT NULL, // "isComplate" INTEGER NOT NULL, // "status" TEXT NOT NULL, // "statusText" TEXT NOT NULL, // "progress_downloaded" INTEGER NOT NULL, // "progress_total" INTEGER NOT NULL, // "url" TEXT, // "quality" TEXT NOT NULL, // "ext" TEXT NOT NULL, // "fileName" TEXT NOT NULL, // "filePath" TEXT NOT NULL, // "musicInfo" TEXT NOT NULL, // "position" INTEGER NOT NULL, // PRIMARY KEY("id") // ); // ` // export const tables = [ // 'table_db_info', // 'table_my_list', // 'table_my_list_music_info', // 'index_index_my_list_music_info', // 'table_my_list_music_info_order', // 'index_index_my_list_music_info_order', // 'table_music_info_other_source', // 'index_index_music_info_other_source', // 'table_lyric', // 'table_music_url', // 'table_download_list', // ] type Tables = 'db_info' | 'my_list' | 'my_list_music_info' | 'index_my_list_music_info' | 'my_list_music_info_order' | 'index_my_list_music_info_order' | 'music_info_other_source' | 'index_music_info_other_source' | 'lyric' | 'music_url' | 'download_list' | 'dislike_list' const tables = new Map() tables.set('db_info', ` CREATE TABLE "db_info" ( "id" INTEGER NOT NULL UNIQUE, "field_name" TEXT, "field_value" TEXT, PRIMARY KEY("id" AUTOINCREMENT) ); `) tables.set('my_list', ` CREATE TABLE "my_list" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "source" TEXT, "sourceListId" TEXT, "position" INTEGER NOT NULL, "locationUpdateTime" INTEGER, PRIMARY KEY("id") ); `) tables.set('my_list_music_info', ` CREATE TABLE "my_list_music_info" ( "id" TEXT NOT NULL, "listId" TEXT NOT NULL, "name" TEXT NOT NULL, "singer" TEXT NOT NULL, "source" TEXT NOT NULL, "interval" TEXT, "meta" TEXT NOT NULL, UNIQUE("id","listId") ); `) tables.set('index_my_list_music_info', ` CREATE INDEX "index_my_list_music_info" ON "my_list_music_info" ( "id", "listId" ); `) tables.set('my_list_music_info_order', ` CREATE TABLE "my_list_music_info_order" ( "listId" TEXT NOT NULL, "musicInfoId" TEXT NOT NULL, "order" INTEGER NOT NULL ); `) tables.set('index_my_list_music_info_order', ` CREATE INDEX "index_my_list_music_info_order" ON "my_list_music_info_order" ( "listId", "musicInfoId" ); `) tables.set('music_info_other_source', ` CREATE TABLE "music_info_other_source" ( "source_id" TEXT NOT NULL, "id" TEXT NOT NULL, "source" TEXT NOT NULL, "name" TEXT NOT NULL, "singer" TEXT NOT NULL, "meta" TEXT NOT NULL, "order" INTEGER NOT NULL, UNIQUE("source_id","id") ); `) tables.set('index_music_info_other_source', ` CREATE INDEX "index_music_info_other_source" ON "music_info_other_source" ( "source_id", "id" ); `) tables.set('lyric', ` -- TODO "meta" TEXT NOT NULL, CREATE TABLE "lyric" ( "id" TEXT NOT NULL, "source" TEXT NOT NULL, "type" TEXT NOT NULL, "text" TEXT NOT NULL ); `) tables.set('music_url', ` CREATE TABLE "music_url" ( "id" TEXT NOT NULL, "url" TEXT NOT NULL ); `) tables.set('download_list', ` CREATE TABLE "download_list" ( "id" TEXT NOT NULL, "isComplate" INTEGER NOT NULL, "status" TEXT NOT NULL, "statusText" TEXT NOT NULL, "progress_downloaded" INTEGER NOT NULL, "progress_total" INTEGER NOT NULL, "url" TEXT, "quality" TEXT NOT NULL, "ext" TEXT NOT NULL, "fileName" TEXT NOT NULL, "filePath" TEXT NOT NULL, "musicInfo" TEXT NOT NULL, "position" INTEGER NOT NULL, PRIMARY KEY("id") ); `) tables.set('dislike_list', ` CREATE TABLE "dislike_list" ( "type" TEXT NOT NULL, "content" TEXT NOT NULL, "meta" TEXT ); `) export default tables export const DB_VERSION = '2' ================================================ FILE: src/main/worker/dbService/verifyDB.ts ================================================ import type Database from 'better-sqlite3' import tables from './tables' const rxp = /\n|\s|;|--.+/g export default (db: Database.Database) => { const result = db.prepare<[]>('SELECT type,name,tbl_name,sql FROM "main".sqlite_master WHERE sql NOT NULL;').all() as Array<{ type: string, name: string, tbl_name: string, sql: string }> const dbTableMap = new Map() for (const info of result) dbTableMap.set(info.name, info.sql.replace(rxp, '')) return Array.from(tables.entries()).every(([name, sql]) => { const dbSql = dbTableMap.get(name) // if (!(dbSql && dbSql == sql.replace(rxp, ''))) { // console.log('dbSql:', dbSql, '\nsql:', sql.replace(rxp, '')) // } // return true return dbSql && dbSql == sql.replace(rxp, '') }) // console.log(dbTableMap) // for (const [name, sql] of tables.entries()) { // const dbSql = dbTableMap.get(name) // if (dbSql) { // if (dbSql == sql.replace(rxp, '')) continue // console.log(dbSql) // console.log(sql.replace(rxp, '')) // } else { // console.log(name) // } // } // if (result.every((info) => { tables.includes() })) } ================================================ FILE: src/main/worker/index.ts ================================================ import { createDBServiceWorker } from './utils' export default () => { return { dbService: createDBServiceWorker(), } } ================================================ FILE: src/main/worker/utils/index.ts ================================================ import { Worker } from 'node:worker_threads' import * as Comlink from 'comlink' import nodeEndpoint from 'comlink/dist/esm/node-adapter' export type DBSeriveTypes = Comlink.Remote export const createDBServiceWorker = () => { const worker: Worker = new Worker(new URL( /* webpackChunkName: 'dbService.worker' */ '../dbService', import.meta.url, )) return Comlink.wrap(nodeEndpoint(worker)) } ================================================ FILE: src/main/worker/utils/worker.ts ================================================ import worker from 'node:worker_threads' import * as Comlink from 'comlink' import nodeEndpoint from 'comlink/dist/esm/node-adapter' export const exposeWorker = (obj: any) => { if (worker.parentPort == null) return Comlink.expose(obj, nodeEndpoint(worker.parentPort)) } ================================================ FILE: src/renderer/.eslintrc.cjs ================================================ /* eslint-env node */ const { base, html, typescript, vue } = require('../../.eslintrc.base.cjs') module.exports = { root: true, ...base, overrides: [ html, vue, { ...typescript, parserOptions: { project: './tsconfig.json', }, }, ], ignorePatterns: [ 'vendors', ], } ================================================ FILE: src/renderer/App.vue ================================================ ================================================ FILE: src/renderer/assets/styles/animate.less ================================================ // https://daneden.github.io/animate.css/ @keyframes bounce { from, 20%, 53%, 80%, to { -webkit-animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } 40%, 43% { -webkit-animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); -webkit-transform: translate3d(0, -30px, 0); transform: translate3d(0, -30px, 0); } 70% { -webkit-animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06); -webkit-transform: translate3d(0, -15px, 0); transform: translate3d(0, -15px, 0); } 90% { -webkit-transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0); } } @keyframes flash { from, 50%, to { opacity: 1; } 25%, 75% { opacity: 0; } } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @keyframes pulse { from { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } 50% { -webkit-transform: scale3d(1.05, 1.05, 1.05); transform: scale3d(1.05, 1.05, 1.05); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @keyframes rubberBand { from { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } 30% { -webkit-transform: scale3d(1.25, 0.75, 1); transform: scale3d(1.25, 0.75, 1); } 40% { -webkit-transform: scale3d(0.75, 1.25, 1); transform: scale3d(0.75, 1.25, 1); } 50% { -webkit-transform: scale3d(1.15, 0.85, 1); transform: scale3d(1.15, 0.85, 1); } 65% { -webkit-transform: scale3d(0.95, 1.05, 1); transform: scale3d(0.95, 1.05, 1); } 75% { -webkit-transform: scale3d(1.05, 0.95, 1); transform: scale3d(1.05, 0.95, 1); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } @keyframes shake { from, to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } 10%, 30%, 50%, 70%, 90% { -webkit-transform: translate3d(-10px, 0, 0); transform: translate3d(-10px, 0, 0); } 20%, 40%, 60%, 80% { -webkit-transform: translate3d(10px, 0, 0); transform: translate3d(10px, 0, 0); } } @keyframes headShake { 0% { -webkit-transform: translateX(0); transform: translateX(0); } 6.5% { -webkit-transform: translateX(-6px) rotateY(-9deg); transform: translateX(-6px) rotateY(-9deg); } 18.5% { -webkit-transform: translateX(5px) rotateY(7deg); transform: translateX(5px) rotateY(7deg); } 31.5% { -webkit-transform: translateX(-3px) rotateY(-5deg); transform: translateX(-3px) rotateY(-5deg); } 43.5% { -webkit-transform: translateX(2px) rotateY(3deg); transform: translateX(2px) rotateY(3deg); } 50% { -webkit-transform: translateX(0); transform: translateX(0); } } @keyframes swing { 20% { -webkit-transform: rotate3d(0, 0, 1, 15deg); transform: rotate3d(0, 0, 1, 15deg); } 40% { -webkit-transform: rotate3d(0, 0, 1, -10deg); transform: rotate3d(0, 0, 1, -10deg); } 60% { -webkit-transform: rotate3d(0, 0, 1, 5deg); transform: rotate3d(0, 0, 1, 5deg); } 80% { -webkit-transform: rotate3d(0, 0, 1, -5deg); transform: rotate3d(0, 0, 1, -5deg); } to { -webkit-transform: rotate3d(0, 0, 1, 0deg); transform: rotate3d(0, 0, 1, 0deg); } } @keyframes tada { from { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } 10%, 20% { -webkit-transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); } 30%, 50%, 70%, 90% { -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); } 40%, 60%, 80% { -webkit-transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); } to { -webkit-transform: scale3d(1, 1, 1); transform: scale3d(1, 1, 1); } } /* originally authored by Nick Pettit - https://github.com/nickpettit/glide */ @keyframes wobble { from { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } 15% { -webkit-transform: translate3d(-25%, 0, 0) rotate3d(0, 0, 1, -5deg); transform: translate3d(-25%, 0, 0) rotate3d(0, 0, 1, -5deg); } 30% { -webkit-transform: translate3d(20%, 0, 0) rotate3d(0, 0, 1, 3deg); transform: translate3d(20%, 0, 0) rotate3d(0, 0, 1, 3deg); } 45% { -webkit-transform: translate3d(-15%, 0, 0) rotate3d(0, 0, 1, -3deg); transform: translate3d(-15%, 0, 0) rotate3d(0, 0, 1, -3deg); } 60% { -webkit-transform: translate3d(10%, 0, 0) rotate3d(0, 0, 1, 2deg); transform: translate3d(10%, 0, 0) rotate3d(0, 0, 1, 2deg); } 75% { -webkit-transform: translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -1deg); transform: translate3d(-5%, 0, 0) rotate3d(0, 0, 1, -1deg); } to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } } @keyframes jello { from, 11.1%, to { -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); } 22.2% { -webkit-transform: skewX(-12.5deg) skewY(-12.5deg); transform: skewX(-12.5deg) skewY(-12.5deg); } 33.3% { -webkit-transform: skewX(6.25deg) skewY(6.25deg); transform: skewX(6.25deg) skewY(6.25deg); } 44.4% { -webkit-transform: skewX(-3.125deg) skewY(-3.125deg); transform: skewX(-3.125deg) skewY(-3.125deg); } 55.5% { -webkit-transform: skewX(1.5625deg) skewY(1.5625deg); transform: skewX(1.5625deg) skewY(1.5625deg); } 66.6% { -webkit-transform: skewX(-0.78125deg) skewY(-0.78125deg); transform: skewX(-0.78125deg) skewY(-0.78125deg); } 77.7% { -webkit-transform: skewX(0.390625deg) skewY(0.390625deg); transform: skewX(0.390625deg) skewY(0.390625deg); } 88.8% { -webkit-transform: skewX(-0.1953125deg) skewY(-0.1953125deg); transform: skewX(-0.1953125deg) skewY(-0.1953125deg); } } @keyframes heartBeat { 0% { -webkit-transform: scale(1); transform: scale(1); } 14% { -webkit-transform: scale(1.3); transform: scale(1.3); } 28% { -webkit-transform: scale(1); transform: scale(1); } 42% { -webkit-transform: scale(1.3); transform: scale(1.3); } 70% { -webkit-transform: scale(1); transform: scale(1); } } @keyframes flipInX { from { transform: perspective(400px) rotate3d(1, 0, 0, 90deg); animation-timing-function: ease-in; opacity: 0; } 40% { transform: perspective(400px) rotate3d(1, 0, 0, -20deg); animation-timing-function: ease-in; } 60% { transform: perspective(400px) rotate3d(1, 0, 0, 10deg); opacity: 1; } 80% { transform: perspective(400px) rotate3d(1, 0, 0, -5deg); } to { transform: perspective(400px); } } @keyframes flipOutX { from { transform: perspective(400px); } 30% { transform: perspective(400px) rotate3d(1, 0, 0, -20deg); opacity: 1; } to { transform: perspective(400px) rotate3d(1, 0, 0, 90deg); opacity: 0; } } @keyframes flipInY { from { transform: perspective(400px) rotate3d(0, 1, 0, 90deg); animation-timing-function: ease-in; opacity: 0; } 40% { transform: perspective(400px) rotate3d(0, 1, 0, -20deg); animation-timing-function: ease-in; } 60% { transform: perspective(400px) rotate3d(0, 1, 0, 10deg); opacity: 1; } 80% { transform: perspective(400px) rotate3d(0, 1, 0, -5deg); } to { transform: perspective(400px); } } @keyframes flipOutY { from { transform: perspective(400px); } 30% { transform: perspective(400px) rotate3d(0, 1, 0, -15deg); opacity: 1; } to { transform: perspective(400px) rotate3d(0, 1, 0, 90deg); opacity: 0; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } @keyframes bounceIn { from, 20%, 40%, 60%, 80%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 0% { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); } 20% { transform: scale3d(1.1, 1.1, 1.1); } 40% { transform: scale3d(0.9, 0.9, 0.9); } 60% { opacity: 1; transform: scale3d(1.03, 1.03, 1.03); } 80% { transform: scale3d(0.97, 0.97, 0.97); } to { opacity: 1; transform: scale3d(1, 1, 1); } } @keyframes bounceOut { 20% { transform: scale3d(0.9, 0.9, 0.9); } 50%, 55% { opacity: 1; transform: scale3d(1.1, 1.1, 1.1); } to { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); } } @keyframes lightSpeedIn { from { transform: translate3d(100%, 0, 0) skewX(-30deg); opacity: 0; } 60% { transform: skewX(20deg); opacity: 1; } 80% { transform: skewX(-5deg); } to { transform: translate3d(0, 0, 0); } } @keyframes lightSpeedOut { from { opacity: 1; } to { transform: translate3d(100%, 0, 0) skewX(30deg); opacity: 0; } } @keyframes rotateIn { from { transform-origin: center; transform: rotate3d(0, 0, 1, -200deg); opacity: 0; } to { transform-origin: center; transform: translate3d(0, 0, 0); opacity: 1; } } @keyframes rotateInDownLeft { from { transform-origin: left bottom; transform: rotate3d(0, 0, 1, -45deg); opacity: 0; } to { transform-origin: left bottom; transform: translate3d(0, 0, 0); opacity: 1; } } @keyframes rotateInDownRight { from { transform-origin: right bottom; transform: rotate3d(0, 0, 1, 45deg); opacity: 0; } to { transform-origin: right bottom; transform: translate3d(0, 0, 0); opacity: 1; } } @keyframes rotateInUpLeft { from { transform-origin: left bottom; transform: rotate3d(0, 0, 1, 45deg); opacity: 0; } to { transform-origin: left bottom; transform: translate3d(0, 0, 0); opacity: 1; } } @keyframes rotateInUpRight { from { transform-origin: right bottom; transform: rotate3d(0, 0, 1, -90deg); opacity: 0; } to { transform-origin: right bottom; transform: translate3d(0, 0, 0); opacity: 1; } } @keyframes rotateOut { from { transform-origin: center; opacity: 1; } to { transform-origin: center; transform: rotate3d(0, 0, 1, 200deg); opacity: 0; } } @keyframes rotateOutDownLeft { from { transform-origin: left bottom; opacity: 1; } to { transform-origin: left bottom; transform: rotate3d(0, 0, 1, 45deg); opacity: 0; } } @keyframes rotateOutDownRight { from { transform-origin: right bottom; opacity: 1; } to { transform-origin: right bottom; transform: rotate3d(0, 0, 1, -45deg); opacity: 0; } } @keyframes rotateOutUpLeft { from { transform-origin: left bottom; opacity: 1; } to { transform-origin: left bottom; transform: rotate3d(0, 0, 1, -45deg); opacity: 0; } } @keyframes rotateOutUpRight { from { transform-origin: right bottom; opacity: 1; } to { transform-origin: right bottom; transform: rotate3d(0, 0, 1, 90deg); opacity: 0; } } @keyframes rollIn { from { opacity: 0; transform: translate3d(-100%, 0, 0) rotate3d(0, 0, 1, -120deg); } to { opacity: 1; transform: translate3d(0, 0, 0); } } @keyframes rollOut { from { opacity: 1; } to { opacity: 0; transform: translate3d(100%, 0, 0) rotate3d(0, 0, 1, 120deg); } } @keyframes zoomIn { from { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); } 50% { opacity: 1; } } @keyframes zoomInDown { from { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(0, -1000px, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } 60% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0); animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes zoomInLeft { from { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(-1000px, 0, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } 60% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(10px, 0, 0); animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes zoomInRight { from { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(1000px, 0, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } 60% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(-10px, 0, 0); animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes zoomInUp { from { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(0, 1000px, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } 60% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0); animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes zoomOut { from { opacity: 1; } 50% { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); } to { opacity: 0; } } @keyframes zoomOutDown { 40% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(0, -60px, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } to { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(0, 2000px, 0); transform-origin: center bottom; animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes zoomOutLeft { 40% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(42px, 0, 0); } to { opacity: 0; transform: scale(0.1) translate3d(-2000px, 0, 0); transform-origin: left center; } } @keyframes zoomOutRight { 40% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(-42px, 0, 0); } to { opacity: 0; transform: scale(0.1) translate3d(2000px, 0, 0); transform-origin: right center; } } @keyframes zoomOutUp { 40% { opacity: 1; transform: scale3d(0.475, 0.475, 0.475) translate3d(0, 60px, 0); animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); } to { opacity: 0; transform: scale3d(0.1, 0.1, 0.1) translate3d(0, -2000px, 0); transform-origin: center bottom; animation-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1); } } @keyframes slideInDown { from { transform: translate3d(0, -100%, 0); visibility: visible; } to { transform: translate3d(0, 0, 0); } } @keyframes slideInLeft { from { transform: translate3d(-100%, 0, 0); visibility: visible; } to { transform: translate3d(0, 0, 0); } } @keyframes slideInRight { from { transform: translate3d(100%, 0, 0); visibility: visible; } to { transform: translate3d(0, 0, 0); } } @keyframes slideInUp { from { transform: translate3d(0, 100%, 0); visibility: visible; } to { transform: translate3d(0, 0, 0); } } @keyframes slideOutDown { from { transform: translate3d(0, 0, 0); } to { visibility: hidden; transform: translate3d(0, 100%, 0); } } @keyframes slideOutLeft { from { transform: translate3d(0, 0, 0); } to { visibility: hidden; transform: translate3d(-100%, 0, 0); } } @keyframes slideOutRight { from { transform: translate3d(0, 0, 0); } to { visibility: hidden; transform: translate3d(100%, 0, 0); } } @keyframes slideOutUp { from { transform: translate3d(0, 0, 0); } to { visibility: hidden; transform: translate3d(0, -100%, 0); } } @keyframes jackInTheBox { from { opacity: 0; transform: scale(0.1) rotate(30deg); transform-origin: center bottom; } 50% { transform: rotate(-10deg); } 70% { transform: rotate(3deg); } to { opacity: 1; transform: scale(1); } } @keyframes hinge { 0% { -webkit-transform-origin: top left; transform-origin: top left; -webkit-animation-timing-function: ease-in-out; animation-timing-function: ease-in-out; } 20%, 60% { -webkit-transform: rotate3d(0, 0, 1, 80deg); transform: rotate3d(0, 0, 1, 80deg); -webkit-transform-origin: top left; transform-origin: top left; -webkit-animation-timing-function: ease-in-out; animation-timing-function: ease-in-out; } 40%, 80% { -webkit-transform: rotate3d(0, 0, 1, 60deg); transform: rotate3d(0, 0, 1, 60deg); -webkit-transform-origin: top left; transform-origin: top left; -webkit-animation-timing-function: ease-in-out; animation-timing-function: ease-in-out; opacity: 1; } to { -webkit-transform: translate3d(0, 700px, 0); transform: translate3d(0, 700px, 0); opacity: 0; } } .flipInX { backface-visibility: visible !important; animation-name: flipInX; } .flipInY { backface-visibility: visible !important; animation-name: flipInY; } .fadeIn { animation-name: fadeIn; } .bounceIn { animation-duration: 0.75s; animation-name: bounceIn; } .lightSpeedIn { animation-name: lightSpeedIn; animation-timing-function: ease-out; } .rotateIn { animation-name: rotateIn; } .rotateInDownLeft { animation-name: rotateInDownLeft; } .rotateInDownRight { animation-name: rotateInDownRight; } .rotateInUpLeft { animation-name: rotateInUpLeft; } .rotateInUpRight { animation-name: rotateInUpRight; } .rollIn { animation-name: rollIn; } .zoomIn { animation-name: zoomIn; } .zoomInDown { animation-name: zoomInDown; } .zoomInLeft { animation-name: zoomInLeft; } .zoomInRight { animation-name: zoomInRight; } .zoomInUp { animation-name: zoomInUp; } .slideInDown { animation-name: slideInDown; } .slideInLeft { animation-name: slideInLeft; } .slideInRight { animation-name: slideInRight; } .slideInUp { animation-name: slideInUp; } .jackInTheBox { -webkit-animation-name: jackInTheBox; animation-name: jackInTheBox; } .flipOutX { animation-duration: 0.75s; animation-name: flipOutX; backface-visibility: visible !important; } .flipOutY { animation-duration: 0.75s; backface-visibility: visible !important; animation-name: flipOutY; } .fadeOut { animation-name: fadeOut; } .bounceOut { animation-duration: 0.75s; animation-name: bounceOut; } .lightSpeedOut { animation-name: lightSpeedOut; animation-timing-function: ease-in; } .rotateOut { animation-name: rotateOut; } .rotateOutDownLeft { animation-name: rotateOutDownLeft; } .rotateOutDownRight { animation-name: rotateOutDownRight; } .rotateOutUpLeft { animation-name: rotateOutUpLeft; } .rotateOutUpRight { animation-name: rotateOutUpRight; } .hinge { animation-duration: 2s; animation-name: hinge; } .rollOut { animation-name: rollOut; } .zoomOut { animation-name: zoomOut; } .zoomOutDown { animation-name: zoomOutDown; } .zoomOutLeft { animation-name: zoomOutLeft; } .zoomOutRight { animation-name: zoomOutRight; } .zoomOutUp { animation-name: zoomOutUp; } .slideOutDown { animation-name: slideOutDown; } .slideOutLeft { animation-name: slideOutLeft; } .slideOutRight { animation-name: slideOutRight; } .slideOutUp { animation-name: slideOutUp; } .bounce { animation-name: bounce; transform-origin: center bottom; } .flash { animation-name: flash; } .pulse { animation-name: pulse; } .rubberBand { animation-name: rubberBand; } .shake { animation-name: shake; } .headShake { animation-timing-function: ease-in-out; animation-name: headShake; } .swing { transform-origin: top center; animation-name: swing; } .tada { animation-name: tada; } .wobble { animation-name: wobble; } .jello { animation-name: jello; transform-origin: center; } .heartBeat { animation-name: heartBeat; animation-duration: 1.3s; animation-timing-function: ease-in-out; } .animated { animation-duration: 0.5s; animation-fill-mode: both; } .animated-slow { animation-duration: 0.8s; animation-fill-mode: both; } .animated-fast { animation-duration: 0.3s; animation-fill-mode: both; } ================================================ FILE: src/renderer/assets/styles/colors.less ================================================ @red-50: #ffebee; @red-100: #ffcdd2; @red-200: #ef9a9a; @red-300: #e57373; @red-400: #ef5350; @red-500: #f44336; @red-600: #e53935; @red-700: #d32f2f; @red-800: #c62828; @red-900: #b71c1c; @red-A100: #ff8a80; @red-A200: #ff5252; @red-A400: #ff1744; @red-A700: #d50000; @red: @red-500; @pink-50: #fce4ec; @pink-100: #f8bbd0; @pink-200: #f48fb1; @pink-300: #f06292; @pink-400: #ec407a; @pink-500: #e91e63; @pink-600: #d81b60; @pink-700: #c2185b; @pink-800: #ad1457; @pink-900: #880e4f; @pink-A100: #ff80ab; @pink-A200: #ff4081; @pink-A400: #f50057; @pink-A700: #c51162; @pink: @pink-500; @purple-50: #f3e5f5; @purple-100: #e1bee7; @purple-200: #ce93d8; @purple-300: #ba68c8; @purple-400: #ab47bc; @purple-500: #9c27b0; @purple-600: #8e24aa; @purple-700: #7b1fa2; @purple-800: #6a1b9a; @purple-900: #4a148c; @purple-A100: #ea80fc; @purple-A200: #e040fb; @purple-A400: #d500f9; @purple-A700: #aa00ff; @purple: @purple-500; @deep-purple-50: #ede7f6; @deep-purple-100: #d1c4e9; @deep-purple-200: #b39ddb; @deep-purple-300: #9575cd; @deep-purple-400: #7e57c2; @deep-purple-500: #673ab7; @deep-purple-600: #5e35b1; @deep-purple-700: #512da8; @deep-purple-800: #4527a0; @deep-purple-900: #311b92; @deep-purple-A100: #b388ff; @deep-purple-A200: #7c4dff; @deep-purple-A400: #651fff; @deep-purple-A700: #6200ea; @deep-purple: @deep-purple-500; @indigo-50: #e8eaf6; @indigo-100: #c5cae9; @indigo-200: #9fa8da; @indigo-300: #7986cb; @indigo-400: #5c6bc0; @indigo-500: #3f51b5; @indigo-600: #3949ab; @indigo-700: #303f9f; @indigo-800: #283593; @indigo-900: #1a237e; @indigo-A100: #8c9eff; @indigo-A200: #536dfe; @indigo-A400: #3d5afe; @indigo-A700: #304ffe; @indigo: @indigo-500; @blue-50: #e3f2fd; @blue-100: #bbdefb; @blue-200: #90caf9; @blue-300: #64b5f6; @blue-400: #42a5f5; @blue-500: #2196f3; @blue-600: #1e88e5; @blue-700: #1976d2; @blue-800: #1565c0; @blue-900: #0d47a1; @blue-A100: #82b1ff; @blue-A200: #448aff; @blue-A400: #2979ff; @blue-A700: #2962ff; @blue: @blue-500; @light-blue-50: #e1f5fe; @light-blue-100: #b3e5fc; @light-blue-200: #81d4fa; @light-blue-300: #4fc3f7; @light-blue-400: #29b6f6; @light-blue-500: #03a9f4; @light-blue-600: #039be5; @light-blue-700: #0288d1; @light-blue-800: #0277bd; @light-blue-900: #01579b; @light-blue-A100: #80d8ff; @light-blue-A200: #40c4ff; @light-blue-A400: #00b0ff; @light-blue-A700: #0091ea; @light-blue: @light-blue-500; @cyan-50: #e0f7fa; @cyan-100: #b2ebf2; @cyan-200: #80deea; @cyan-300: #4dd0e1; @cyan-400: #26c6da; @cyan-500: #00bcd4; @cyan-600: #00acc1; @cyan-700: #0097a7; @cyan-800: #00838f; @cyan-900: #006064; @cyan-A100: #84ffff; @cyan-A200: #18ffff; @cyan-A400: #00e5ff; @cyan-A700: #00b8d4; @cyan: @cyan-500; @teal-50: #e0f2f1; @teal-100: #b2dfdb; @teal-200: #80cbc4; @teal-300: #4db6ac; @teal-400: #26a69a; @teal-500: #009688; @teal-600: #00897b; @teal-700: #00796b; @teal-800: #00695c; @teal-900: #004d40; @teal-A100: #a7ffeb; @teal-A200: #64ffda; @teal-A400: #1de9b6; @teal-A700: #00bfa5; @teal: @teal-500; @green-50: #e8f5e9; @green-100: #c8e6c9; @green-200: #a5d6a7; @green-300: #81c784; @green-400: #66bb6a; @green-500: #4caf50; @green-600: #43a047; @green-700: #388e3c; @green-800: #2e7d32; @green-900: #1b5e20; @green-A100: #b9f6ca; @green-A200: #69f0ae; @green-A400: #00e676; @green-A700: #00c853; @green: @green-500; @light-green-50: #f1f8e9; @light-green-100: #dcedc8; @light-green-200: #c5e1a5; @light-green-300: #aed581; @light-green-400: #9ccc65; @light-green-500: #8bc34a; @light-green-600: #7cb342; @light-green-700: #689f38; @light-green-800: #558b2f; @light-green-900: #33691e; @light-green-A100: #ccff90; @light-green-A200: #b2ff59; @light-green-A400: #76ff03; @light-green-A700: #64dd17; @light-green: @light-green-500; @lime-50: #f9fbe7; @lime-100: #f0f4c3; @lime-200: #e6ee9c; @lime-300: #dce775; @lime-400: #d4e157; @lime-500: #cddc39; @lime-600: #c0ca33; @lime-700: #afb42b; @lime-800: #9e9d24; @lime-900: #827717; @lime-A100: #f4ff81; @lime-A200: #eeff41; @lime-A400: #c6ff00; @lime-A700: #aeea00; @lime: @lime-500; @yellow-50: #fffde7; @yellow-100: #fff9c4; @yellow-200: #fff59d; @yellow-300: #fff176; @yellow-400: #ffee58; @yellow-500: #fec60a; @yellow-600: #fdd835; @yellow-700: #fbc02d; @yellow-800: #f9a825; @yellow-900: #f57f17; @yellow-A100: #ffff8d; @yellow-A200: #ffff00; @yellow-A400: #ffea00; @yellow-A700: #ffd600; @yellow: @yellow-700; @amber-50: #fff8e1; @amber-100: #ffecb3; @amber-200: #ffe082; @amber-300: #ffd54f; @amber-400: #ffca28; @amber-500: #ffc107; @amber-600: #ffb300; @amber-700: #ffa000; @amber-800: #ff8f00; @amber-900: #ff6f00; @amber-A100: #ffe57f; @amber-A200: #ffd740; @amber-A400: #ffc400; @amber-A700: #ffab00; @amber: @amber-500; @orange-50: #fff3e0; @orange-100: #ffe0b2; @orange-200: #ffcc80; @orange-300: #ffb74d; @orange-400: #ffa726; @orange-500: #ff9800; @orange-600: #fb8c00; @orange-700: #f57c00; @orange-800: #ef6c00; @orange-900: #e65100; @orange-A100: #ffd180; @orange-A200: #ffab40; @orange-A400: #ff9100; @orange-A700: #ff6d00; @orange: @orange-500; @deep-orange-50: #fbe9e7; @deep-orange-100: #ffccbc; @deep-orange-200: #ffab91; @deep-orange-300: #ff8a65; @deep-orange-400: #ff7043; @deep-orange-500: #ff5722; @deep-orange-600: #f4511e; @deep-orange-700: #e64a19; @deep-orange-800: #d84315; @deep-orange-900: #bf360c; @deep-orange-A100: #ff9e80; @deep-orange-A200: #ff6e40; @deep-orange-A400: #ff3d00; @deep-orange-A700: #dd2c00; @deep-orange: @deep-orange-500; @brown-50: #efebe9; @brown-100: #d7ccc8; @brown-200: #bcaaa4; @brown-300: #a1887f; @brown-400: #8d6e63; @brown-500: #795548; @brown-600: #6d4c41; @brown-700: #5d4037; @brown-800: #4e342e; @brown-900: #3e2723; @brown-A100: #d7ccc8; @brown-A200: #bcaaa4; @brown-A400: #8d6e63; @brown-A700: #5d4037; @brown: @brown-500; @grey-50: #fafafa; @grey-100: #f5f5f5; @grey-200: #eeeeee; @grey-300: #e0e0e0; @grey-400: #bdbdbd; @grey-500: #9e9e9e; @rgb-grey-500: "158, 158, 158"; @grey-600: #757575; @grey-700: #616161; @grey-800: #424242; @grey-900: #212121; @grey-A100: #f5f5f5; @grey-A200: #eeeeee; @grey-A400: #bdbdbd; @grey-A700: #616161; @grey: @grey-500; @blue-grey-50: #eceff1; @blue-grey-100: #cfd8dc; @blue-grey-200: #b0bec5; @blue-grey-300: #90a4ae; @blue-grey-400: #78909c; @blue-grey-500: #607d8b; @blue-grey-600: #546e7a; @blue-grey-700: #455a64; @blue-grey-800: #37474f; @blue-grey-900: #263238; @blue-grey-A100: #cfd8dc; @blue-grey-A200: #b0bec5; @blue-grey-A400: #78909c; @blue-grey-A700: #455a64; @blue-grey: @blue-grey-500; @black: #000000; @rgb-black: "0,0,0"; @white: #ffffff; @rgb-white: "255,255,255"; ================================================ FILE: src/renderer/assets/styles/index.less ================================================ @import './reset.less'; @import './animate.less'; @import './layout.less'; *, *::after, *::before { -webkit-user-drag: none; } :root { --color-primary: rgb(77, 175, 124); --color-primary-alpha-100: rgba(77, 175, 124, 0.90); --color-primary-alpha-200: rgba(77, 175, 124, 0.80); --color-primary-alpha-300: rgba(77, 175, 124, 0.70); --color-primary-alpha-400: rgba(77, 175, 124, 0.60); --color-primary-alpha-500: rgba(77, 175, 124, 0.50); --color-primary-alpha-600: rgba(77, 175, 124, 0.40); --color-primary-alpha-700: rgba(77, 175, 124, 0.30); --color-primary-alpha-800: rgba(77, 175, 124, 0.20); --color-primary-alpha-900: rgba(77, 175, 124, 0.10); --color-primary-dark-100: rgb(69,158,112); --color-primary-dark-100-alpha-100: rgba(69, 158, 112, 0.90); --color-primary-dark-100-alpha-200: rgba(69, 158, 112, 0.80); --color-primary-dark-100-alpha-300: rgba(69, 158, 112, 0.70); --color-primary-dark-100-alpha-400: rgba(69, 158, 112, 0.60); --color-primary-dark-100-alpha-500: rgba(69, 158, 112, 0.50); --color-primary-dark-100-alpha-600: rgba(69, 158, 112, 0.40); --color-primary-dark-100-alpha-700: rgba(69, 158, 112, 0.30); --color-primary-dark-100-alpha-800: rgba(69, 158, 112, 0.20); --color-primary-dark-100-alpha-900: rgba(69, 158, 112, 0.10); --color-primary-dark-200: rgb(62,142,101); --color-primary-dark-200-alpha-100: rgba(62, 142, 101, 0.90); --color-primary-dark-200-alpha-200: rgba(62, 142, 101, 0.80); --color-primary-dark-200-alpha-300: rgba(62, 142, 101, 0.70); --color-primary-dark-200-alpha-400: rgba(62, 142, 101, 0.60); --color-primary-dark-200-alpha-500: rgba(62, 142, 101, 0.50); --color-primary-dark-200-alpha-600: rgba(62, 142, 101, 0.40); --color-primary-dark-200-alpha-700: rgba(62, 142, 101, 0.30); --color-primary-dark-200-alpha-800: rgba(62, 142, 101, 0.20); --color-primary-dark-200-alpha-900: rgba(62, 142, 101, 0.10); --color-primary-dark-300: rgb(56,128,91); --color-primary-dark-300-alpha-100: rgba(56, 128, 91, 0.90); --color-primary-dark-300-alpha-200: rgba(56, 128, 91, 0.80); --color-primary-dark-300-alpha-300: rgba(56, 128, 91, 0.70); --color-primary-dark-300-alpha-400: rgba(56, 128, 91, 0.60); --color-primary-dark-300-alpha-500: rgba(56, 128, 91, 0.50); --color-primary-dark-300-alpha-600: rgba(56, 128, 91, 0.40); --color-primary-dark-300-alpha-700: rgba(56, 128, 91, 0.30); --color-primary-dark-300-alpha-800: rgba(56, 128, 91, 0.20); --color-primary-dark-300-alpha-900: rgba(56, 128, 91, 0.10); --color-primary-dark-400: rgb(50,115,82); --color-primary-dark-400-alpha-100: rgba(50, 115, 82, 0.90); --color-primary-dark-400-alpha-200: rgba(50, 115, 82, 0.80); --color-primary-dark-400-alpha-300: rgba(50, 115, 82, 0.70); --color-primary-dark-400-alpha-400: rgba(50, 115, 82, 0.60); --color-primary-dark-400-alpha-500: rgba(50, 115, 82, 0.50); --color-primary-dark-400-alpha-600: rgba(50, 115, 82, 0.40); --color-primary-dark-400-alpha-700: rgba(50, 115, 82, 0.30); --color-primary-dark-400-alpha-800: rgba(50, 115, 82, 0.20); --color-primary-dark-400-alpha-900: rgba(50, 115, 82, 0.10); --color-primary-dark-500: rgb(45,104,74); --color-primary-dark-500-alpha-100: rgba(45, 104, 74, 0.90); --color-primary-dark-500-alpha-200: rgba(45, 104, 74, 0.80); --color-primary-dark-500-alpha-300: rgba(45, 104, 74, 0.70); --color-primary-dark-500-alpha-400: rgba(45, 104, 74, 0.60); --color-primary-dark-500-alpha-500: rgba(45, 104, 74, 0.50); --color-primary-dark-500-alpha-600: rgba(45, 104, 74, 0.40); --color-primary-dark-500-alpha-700: rgba(45, 104, 74, 0.30); --color-primary-dark-500-alpha-800: rgba(45, 104, 74, 0.20); --color-primary-dark-500-alpha-900: rgba(45, 104, 74, 0.10); --color-primary-dark-600: rgb(41,94,67); --color-primary-dark-600-alpha-100: rgba(41, 94, 67, 0.90); --color-primary-dark-600-alpha-200: rgba(41, 94, 67, 0.80); --color-primary-dark-600-alpha-300: rgba(41, 94, 67, 0.70); --color-primary-dark-600-alpha-400: rgba(41, 94, 67, 0.60); --color-primary-dark-600-alpha-500: rgba(41, 94, 67, 0.50); --color-primary-dark-600-alpha-600: rgba(41, 94, 67, 0.40); --color-primary-dark-600-alpha-700: rgba(41, 94, 67, 0.30); --color-primary-dark-600-alpha-800: rgba(41, 94, 67, 0.20); --color-primary-dark-600-alpha-900: rgba(41, 94, 67, 0.10); --color-primary-dark-700: rgb(37,85,60); --color-primary-dark-700-alpha-100: rgba(37, 85, 60, 0.90); --color-primary-dark-700-alpha-200: rgba(37, 85, 60, 0.80); --color-primary-dark-700-alpha-300: rgba(37, 85, 60, 0.70); --color-primary-dark-700-alpha-400: rgba(37, 85, 60, 0.60); --color-primary-dark-700-alpha-500: rgba(37, 85, 60, 0.50); --color-primary-dark-700-alpha-600: rgba(37, 85, 60, 0.40); --color-primary-dark-700-alpha-700: rgba(37, 85, 60, 0.30); --color-primary-dark-700-alpha-800: rgba(37, 85, 60, 0.20); --color-primary-dark-700-alpha-900: rgba(37, 85, 60, 0.10); --color-primary-dark-800: rgb(33,77,54); --color-primary-dark-800-alpha-100: rgba(33, 77, 54, 0.90); --color-primary-dark-800-alpha-200: rgba(33, 77, 54, 0.80); --color-primary-dark-800-alpha-300: rgba(33, 77, 54, 0.70); --color-primary-dark-800-alpha-400: rgba(33, 77, 54, 0.60); --color-primary-dark-800-alpha-500: rgba(33, 77, 54, 0.50); --color-primary-dark-800-alpha-600: rgba(33, 77, 54, 0.40); --color-primary-dark-800-alpha-700: rgba(33, 77, 54, 0.30); --color-primary-dark-800-alpha-800: rgba(33, 77, 54, 0.20); --color-primary-dark-800-alpha-900: rgba(33, 77, 54, 0.10); --color-primary-dark-900: rgb(30,69,49); --color-primary-dark-900-alpha-100: rgba(30, 69, 49, 0.90); --color-primary-dark-900-alpha-200: rgba(30, 69, 49, 0.80); --color-primary-dark-900-alpha-300: rgba(30, 69, 49, 0.70); --color-primary-dark-900-alpha-400: rgba(30, 69, 49, 0.60); --color-primary-dark-900-alpha-500: rgba(30, 69, 49, 0.50); --color-primary-dark-900-alpha-600: rgba(30, 69, 49, 0.40); --color-primary-dark-900-alpha-700: rgba(30, 69, 49, 0.30); --color-primary-dark-900-alpha-800: rgba(30, 69, 49, 0.20); --color-primary-dark-900-alpha-900: rgba(30, 69, 49, 0.10); --color-primary-dark-1000: rgb(27,62,44); --color-primary-dark-1000-alpha-100: rgba(27, 62, 44, 0.90); --color-primary-dark-1000-alpha-200: rgba(27, 62, 44, 0.80); --color-primary-dark-1000-alpha-300: rgba(27, 62, 44, 0.70); --color-primary-dark-1000-alpha-400: rgba(27, 62, 44, 0.60); --color-primary-dark-1000-alpha-500: rgba(27, 62, 44, 0.50); --color-primary-dark-1000-alpha-600: rgba(27, 62, 44, 0.40); --color-primary-dark-1000-alpha-700: rgba(27, 62, 44, 0.30); --color-primary-dark-1000-alpha-800: rgba(27, 62, 44, 0.20); --color-primary-dark-1000-alpha-900: rgba(27, 62, 44, 0.10); --color-primary-light-100: rgb(113,191,150); --color-primary-light-100-alpha-100: rgba(113, 191, 150, 0.90); --color-primary-light-100-alpha-200: rgba(113, 191, 150, 0.80); --color-primary-light-100-alpha-300: rgba(113, 191, 150, 0.70); --color-primary-light-100-alpha-400: rgba(113, 191, 150, 0.60); --color-primary-light-100-alpha-500: rgba(113, 191, 150, 0.50); --color-primary-light-100-alpha-600: rgba(113, 191, 150, 0.40); --color-primary-light-100-alpha-700: rgba(113, 191, 150, 0.30); --color-primary-light-100-alpha-800: rgba(113, 191, 150, 0.20); --color-primary-light-100-alpha-900: rgba(113, 191, 150, 0.10); --color-primary-light-200: rgb(141,204,171); --color-primary-light-200-alpha-100: rgba(141, 204, 171, 0.90); --color-primary-light-200-alpha-200: rgba(141, 204, 171, 0.80); --color-primary-light-200-alpha-300: rgba(141, 204, 171, 0.70); --color-primary-light-200-alpha-400: rgba(141, 204, 171, 0.60); --color-primary-light-200-alpha-500: rgba(141, 204, 171, 0.50); --color-primary-light-200-alpha-600: rgba(141, 204, 171, 0.40); --color-primary-light-200-alpha-700: rgba(141, 204, 171, 0.30); --color-primary-light-200-alpha-800: rgba(141, 204, 171, 0.20); --color-primary-light-200-alpha-900: rgba(141, 204, 171, 0.10); --color-primary-light-300: rgb(164,214,188); --color-primary-light-300-alpha-100: rgba(164, 214, 188, 0.90); --color-primary-light-300-alpha-200: rgba(164, 214, 188, 0.80); --color-primary-light-300-alpha-300: rgba(164, 214, 188, 0.70); --color-primary-light-300-alpha-400: rgba(164, 214, 188, 0.60); --color-primary-light-300-alpha-500: rgba(164, 214, 188, 0.50); --color-primary-light-300-alpha-600: rgba(164, 214, 188, 0.40); --color-primary-light-300-alpha-700: rgba(164, 214, 188, 0.30); --color-primary-light-300-alpha-800: rgba(164, 214, 188, 0.20); --color-primary-light-300-alpha-900: rgba(164, 214, 188, 0.10); --color-primary-light-400: rgb(182,222,201); --color-primary-light-400-alpha-100: rgba(182, 222, 201, 0.90); --color-primary-light-400-alpha-200: rgba(182, 222, 201, 0.80); --color-primary-light-400-alpha-300: rgba(182, 222, 201, 0.70); --color-primary-light-400-alpha-400: rgba(182, 222, 201, 0.60); --color-primary-light-400-alpha-500: rgba(182, 222, 201, 0.50); --color-primary-light-400-alpha-600: rgba(182, 222, 201, 0.40); --color-primary-light-400-alpha-700: rgba(182, 222, 201, 0.30); --color-primary-light-400-alpha-800: rgba(182, 222, 201, 0.20); --color-primary-light-400-alpha-900: rgba(182, 222, 201, 0.10); --color-primary-light-500: rgb(197,229,212); --color-primary-light-500-alpha-100: rgba(197, 229, 212, 0.90); --color-primary-light-500-alpha-200: rgba(197, 229, 212, 0.80); --color-primary-light-500-alpha-300: rgba(197, 229, 212, 0.70); --color-primary-light-500-alpha-400: rgba(197, 229, 212, 0.60); --color-primary-light-500-alpha-500: rgba(197, 229, 212, 0.50); --color-primary-light-500-alpha-600: rgba(197, 229, 212, 0.40); --color-primary-light-500-alpha-700: rgba(197, 229, 212, 0.30); --color-primary-light-500-alpha-800: rgba(197, 229, 212, 0.20); --color-primary-light-500-alpha-900: rgba(197, 229, 212, 0.10); --color-primary-light-600: rgb(209,234,221); --color-primary-light-600-alpha-100: rgba(209, 234, 221, 0.90); --color-primary-light-600-alpha-200: rgba(209, 234, 221, 0.80); --color-primary-light-600-alpha-300: rgba(209, 234, 221, 0.70); --color-primary-light-600-alpha-400: rgba(209, 234, 221, 0.60); --color-primary-light-600-alpha-500: rgba(209, 234, 221, 0.50); --color-primary-light-600-alpha-600: rgba(209, 234, 221, 0.40); --color-primary-light-600-alpha-700: rgba(209, 234, 221, 0.30); --color-primary-light-600-alpha-800: rgba(209, 234, 221, 0.20); --color-primary-light-600-alpha-900: rgba(209, 234, 221, 0.10); --color-primary-light-700: rgb(218,238,228); --color-primary-light-700-alpha-100: rgba(218, 238, 228, 0.90); --color-primary-light-700-alpha-200: rgba(218, 238, 228, 0.80); --color-primary-light-700-alpha-300: rgba(218, 238, 228, 0.70); --color-primary-light-700-alpha-400: rgba(218, 238, 228, 0.60); --color-primary-light-700-alpha-500: rgba(218, 238, 228, 0.50); --color-primary-light-700-alpha-600: rgba(218, 238, 228, 0.40); --color-primary-light-700-alpha-700: rgba(218, 238, 228, 0.30); --color-primary-light-700-alpha-800: rgba(218, 238, 228, 0.20); --color-primary-light-700-alpha-900: rgba(218, 238, 228, 0.10); --color-primary-light-800: rgb(225,241,233); --color-primary-light-800-alpha-100: rgba(225, 241, 233, 0.90); --color-primary-light-800-alpha-200: rgba(225, 241, 233, 0.80); --color-primary-light-800-alpha-300: rgba(225, 241, 233, 0.70); --color-primary-light-800-alpha-400: rgba(225, 241, 233, 0.60); --color-primary-light-800-alpha-500: rgba(225, 241, 233, 0.50); --color-primary-light-800-alpha-600: rgba(225, 241, 233, 0.40); --color-primary-light-800-alpha-700: rgba(225, 241, 233, 0.30); --color-primary-light-800-alpha-800: rgba(225, 241, 233, 0.20); --color-primary-light-800-alpha-900: rgba(225, 241, 233, 0.10); --color-primary-light-900: rgb(231,244,237); --color-primary-light-900-alpha-100: rgba(231, 244, 237, 0.90); --color-primary-light-900-alpha-200: rgba(231, 244, 237, 0.80); --color-primary-light-900-alpha-300: rgba(231, 244, 237, 0.70); --color-primary-light-900-alpha-400: rgba(231, 244, 237, 0.60); --color-primary-light-900-alpha-500: rgba(231, 244, 237, 0.50); --color-primary-light-900-alpha-600: rgba(231, 244, 237, 0.40); --color-primary-light-900-alpha-700: rgba(231, 244, 237, 0.30); --color-primary-light-900-alpha-800: rgba(231, 244, 237, 0.20); --color-primary-light-900-alpha-900: rgba(231, 244, 237, 0.10); --color-primary-light-1000: rgb(255,255,255); --color-primary-light-1000-alpha-100: rgba(255, 255, 255, 0.90); --color-primary-light-1000-alpha-200: rgba(255, 255, 255, 0.80); --color-primary-light-1000-alpha-300: rgba(255, 255, 255, 0.70); --color-primary-light-1000-alpha-400: rgba(255, 255, 255, 0.60); --color-primary-light-1000-alpha-500: rgba(255, 255, 255, 0.50); --color-primary-light-1000-alpha-600: rgba(255, 255, 255, 0.40); --color-primary-light-1000-alpha-700: rgba(255, 255, 255, 0.30); --color-primary-light-1000-alpha-800: rgba(255, 255, 255, 0.20); --color-primary-light-1000-alpha-900: rgba(255, 255, 255, 0.10); --color-theme: rgb(77, 175, 124); // --color-scrollbar-track: // --color-900: #fff; // --color-800: #fafafa; // --color-700: #f5f5f5; // --color-600: #eeeeee; // --color-500: #e0e0e0; // --color-400: #bdbdbd; // --color-300: #9e9e9e; // --color-200: #757575; // --color-100: #616161; // --color-050: #424242; // --color-000: #212121; // --color-000: #fff; // --color-050: #fafafa; // --color-100: #f5f5f5; // --color-200: #eeeeee; // --color-300: #e0e0e0; // --color-400: #bdbdbd; // --color-500: #9e9e9e; // --color-600: #757575; // --color-700: #616161; // --color-800: #424242; // --color-900: #212121; --color-000: rgb(255,255,255); --color-050: rgb(244,244,244); --color-100: rgb(233,233,233); --color-150: rgb(222,222,222); --color-200: rgb(211,211,211); --color-250: rgb(200,200,200); --color-300: rgb(188,188,188); --color-350: rgb(177,177,177); --color-400: rgb(166,166,166); --color-450: rgb(155,155,155); --color-500: rgb(144,144,144); --color-550: rgb(133,133,133); --color-600: rgb(122,122,122); --color-650: rgb(111,111,111); --color-700: rgb(100,100,100); --color-750: rgb(89,89,89); --color-800: rgb(77,77,77); --color-850: rgb(66,66,66); --color-900: rgb(55,55,55); --color-950: rgb(44,44,44); --color-1000: rgb(33, 33, 33); --color-app-background: var(--color-primary-light-600-alpha-600); --color-main-background: rgba(255, 255, 255, 0.9); --color-nav-font: var(--color-primary); // --color-app-background: rgba(0, 0, 0, .5); // --color-main-background: rgba(0, 0, 0, 0.26); --color-btn-hide: #3bc2b2; --color-btn-min: #85c43b; // --color-btn-max: #e7aa36; --color-btn-close: #fab4a0; --color-badge-primary: var(--color-primary); --color-badge-secondary: #4baed5; --color-badge-tertiary: #e7aa36; --color-font: var(--color-850); --color-font-label: var(--color-450); --color-primary-font: var(--color-primary); --color-primary-font-hover: var(--color-primary-alpha-300); --color-primary-font-active: var(--color-primary-dark-100-alpha-200); --color-primary-background: var(--color-primary-light-400-alpha-700); --color-primary-background-hover: var(--color-primary-light-300-alpha-800); --color-primary-background-active: var(--color-primary-light-100-alpha-800); --color-button-font: var(--color-primary-alpha-100); --color-button-font-selected: var(--color-primary-dark-100-alpha-100); --color-button-background: var(--color-primary-light-400-alpha-700); --color-button-background-selected: var(--color-primary-alpha-600); --color-button-background-hover: var(--color-primary-light-300-alpha-600); --color-button-background-active: var(--color-primary-light-100-alpha-600); --color-list-header-border-bottom: 1px solid var(--color-primary-alpha-900); --color-content-background: var(--color-primary-light-1000); --background-image: none; --background-image-position: center; --background-image-size: cover; } html { font-size: 16px; } .nobreak { white-space: nowrap; } .auto-hidden { .mixin-ellipsis-1(); } .center { text-align: center; } .break { word-break: break-all; } .select { user-select: text; } .no-select { user-select: none; } .badge { display: inline-block; padding: 0.25em 0.4em; font-size: .7em; // font-weight: 700; line-height: 1.2; text-align: center; white-space: nowrap; // vertical-align: baseline; vertical-align: text-top; // border-radius: 2px; // &.badge-light { // background-color: #f8f9fa; // } // &.badge-secondary { // color: #fff; // background-color: #6c757d; // } // &.badge-info { // color: #fff; // background-color: #4baed5; // } // &.badge-warning { // color: #fff; // background-color: #ffa45a; // } // &.badge-danger { // color: #fff; // background-color: #ff705a; // } // &.badge-success { // color: #fff; // background-color: #32bc63; // } &.badge-theme-primary { color: var(--color-badge-primary); } &.badge-theme-secondary { color: var(--color-badge-secondary); } &.badge-theme-tertiary { color: var(--color-badge-tertiary); } } small { font-size: .8em; } .small { font-size: .9em; } .tip { color: var(--color-label); } strong { font-weight: bold; } .underline { text-decoration: underline; } svg { transition: @transition-normal; transition-property: fill; } button, input, textarea, a { color: var(--color-font); } input, textarea { &::placeholder { color: var(--color-font-label); } } ::selection { color: var(--color-primary-dark-500-alpha-200); background: var(--color-primary-light-100-alpha-500); } .hover, a { cursor: pointer; transition: color .2s ease; &:hover { color: var(--color-primary-font-hover); } &:active { color: var(--color-primary-font-active); } } .scroll { overflow: auto; &::-webkit-scrollbar { width: 6px; height: 6px; background-color: rgba(0, 0, 0, 0); } &::-webkit-scrollbar-track { background-color: var(--color-primary-light-100-alpha-800); border-radius: 3px; // background-color: rgba(0, 0, 0, 0.1); } &::-webkit-scrollbar-thumb { border-radius: 3px; background-color: var(--color-primary-alpha-600); // background-color: rgba(0, 0, 0, 0.2); transition: background-color 0.4s ease; } &::-webkit-scrollbar-thumb:hover { border-radius: 3px; background-color: var(--color-primary-alpha-400); // background-color: rgba(0, 0, 0, 0.4); transition: background-color 0.4s ease; } } .thead { border-bottom: var(--color-list-header-border-bottom); padding-right: 6px; flex: none; .num { .nobreak(); .center(); color: var(--color-font-label); } // box-shadow: 0 0 2px var(--color-primary-dark-500-alpha-800); // position: relative; // z-index: 2; } table { width: 100%; border-spacing: 0; border-collapse: collapse; overflow: hidden; color: var(--color-font); th { font-size: 12px; text-align: left; line-height: 38px; padding: 0 6px; } } .list { width: 100%; overflow: hidden; color: var(--color-font); flex: auto; .list-item { height: 100%; display: flex; flex-flow: row nowrap; align-items: center; // border-top: 1px solid rgba(0, 0, 0, 0.12); transition: 0.2s ease; transition-property: background-color, color; // border-bottom: 1px solid @color-theme_2-line; box-sizing: border-box; font-size: 12px; &:hover { background-color: var(--color-primary-background-hover); // .list-item-cell-action { // display: block; // } } &.active { background-color: var(--color-primary-background-active); } &.selected { background-color: var(--color-primary-background-hover); } &.disabled { opacity: .5; } .list-item-cell { flex: none; padding: 0 6px; position: relative; // transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1); line-height: 16px; vertical-align: middle; box-sizing: border-box; .mixin-ellipsis-1(); &.auto { flex: auto; } &.num, .num { .nobreak(); .center(); padding-left: 3px; padding-right: 3px; font-size: 11px; color: var(--color-font-label); } &.name { display: flex; flex-flow: row nowrap; overflow: hidden; white-space: initial; text-overflow: initial; align-items: center; >.name { .mixin-ellipsis-1(); } } .badge { margin-left: 3px; opacity: .85; } } // .list-item-cell-action { // white-space: nowrap; // display: none; // flex: auto; // text-align: right; // // position: absolute; // // right: 5px; // // top: -2px; // // opacity: 0; // // transition: opacity .1s ease; // } } } .copying { .no-select { display: none !important; } } .gap-left { + .gap-left { margin-left: 20px; } } .gap-top { &.top { margin-top: 25px; } + .gap-top { margin-top: 10px; } } .color-picker { border-radius: @radius-border !important; } .list-active-enter-active, .list-active-leave-active { transition: .13s ease; transition-property: width, opacity; } .list-active-enter-from, .list-active-leave-to { width: 0.25em !important; opacity: 0; } .play-active-enter-active, .play-active-leave-active { transition: .13s ease; transition-property: transform, opacity; } .play-active-enter-from, .play-active-leave-to { transform: scale(0.3); opacity: 0; } ================================================ FILE: src/renderer/assets/styles/layout.less ================================================ @import './variables.less'; /*自动隐藏文字*/ .mixin-ellipsis-1() { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .mixin-ellipsis(@n: 1) { display: -webkit-box; overflow: hidden; text-overflow: ellipsis; word-wrap: break-word; word-break: break-all; white-space: normal !important; -webkit-line-clamp: @n; -webkit-box-orient: vertical; } .mixin-ellipsis-2() { display: -webkit-box; overflow: hidden; text-overflow: ellipsis; word-wrap: break-word; word-break: break-all; white-space: normal !important; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .mixin-after() { display: block; position: absolute; content: ''; } ================================================ FILE: src/renderer/assets/styles/reset.less ================================================ // https://github.com/microsoft/vscode/blob/2dd0bca3954d4c03c427d6b447205b68817bd000/src/vs/workbench/browser/media/style.css /* Font Families (with CJK support) */ .windows { font-family: "Segoe WPC", "Segoe UI", sans-serif; } .windows:lang(zh-Hans) { font-family:"Microsoft YaHei", "Segoe WPC", "Segoe UI", sans-serif; } .windows:lang(zh-Hant) { font-family:"Microsoft Jhenghei", "Segoe WPC", "Segoe UI", sans-serif; } .windows:lang(ja) { font-family:"Yu Gothic UI", "Meiryo UI", "Segoe WPC", "Segoe UI", sans-serif; } .windows:lang(ko) { font-family:"Malgun Gothic", "Dotom", "Segoe WPC", "Segoe UI", sans-serif; } .mac { font-family: -apple-system, BlinkMacSystemFont, sans-serif; } .mac:lang(zh-Hans) { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", sans-serif; } .mac:lang(zh-Hant) { font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif; } .mac:lang(ja) { font-family: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic Pro", sans-serif; } .mac:lang(ko) { font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Nanum Gothic", "AppleGothic", sans-serif; } /* Linux: add `system-ui` as first font and not `Ubuntu` to allow other distribution pick their standard OS font */ .linux { font-family: system-ui, "Ubuntu", "Droid Sans", sans-serif; } .linux:lang(zh-Hans) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans SC", "Source Han Sans CN", "Source Han Sans", sans-serif; } .linux:lang(zh-Hant) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans TC", "Source Han Sans TW", "Source Han Sans", sans-serif; } .linux:lang(ja) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans J", "Source Han Sans JP", "Source Han Sans", sans-serif; } .linux:lang(ko) { font-family: system-ui, "Ubuntu", "Droid Sans", "Source Han Sans K", "Source Han Sans JR", "Source Han Sans", "UnDotum", "FBaekmuk Gulim", sans-serif; } html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; font-size: inherit; vertical-align: baseline; } input, button, textarea { font-family: inherit; } // html { // font-family: // // windows // Segoe WPC,Segoe UI, // Microsoft YaHei, // Microsoft Jhenghei, // Yu Gothic UI,Meiryo UI, // Malgun Gothic,Dotom, // // mac // -apple-system,BlinkMacSystemFont, // PingFang SC, // PingFang TC, // Hiragino Kaku Gothic Pro, // Apple SD Gothic Neo, // Hiragino Sans GB,Nanum Gothic,AppleGothic, // // linux // system-ui,Ubuntu,Droid Sans, // Source Han Sans SC,Source Han Sans CN, // Source Han Sans TC,Source Han Sans TW, // Source Han Sans J,Source Han Sans JP, // Source Han Sans K,Source Han Sans JR, // Source Han Sans,UnDotum,FBaekmuk Gulim, // sans-serif; // } /* HTML5 display-role reset for older browsers */ article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } // html { // } body { line-height: 1.2; } ol, ul { list-style: none; } blockquote, q { quotes: none; } blockquote:before, blockquote:after, q:before, q:after { content: ''; content: none; } table { border-collapse: collapse; border-spacing: 0; } ================================================ FILE: src/renderer/assets/styles/variables.less ================================================ @import './colors.less'; // Width @width-app-left: 6.6%; // Height @height-toolbar: 54px; @height-player: 66px; // Shadow @shadow-app: 8px; // Radius @radius-progress-border: 5px; @radius-border: 4px; @transition-slow: .6s ease; @transition-normal: .4s ease; @transition-fast: .3s ease; @form-radius: 3px; ================================================ FILE: src/renderer/components/base/Btn.vue ================================================ ================================================ FILE: src/renderer/components/base/Checkbox.vue ================================================ ================================================ FILE: src/renderer/components/base/Input.vue ================================================ ================================================ FILE: src/renderer/components/base/Menu.vue ================================================ ================================================ FILE: src/renderer/components/base/MusicList.vue ================================================ ================================================ FILE: src/renderer/components/base/Popup.vue ================================================ ================================================ FILE: src/renderer/components/base/Selection.vue ================================================ ================================================ FILE: src/renderer/components/base/SliderBar.vue ================================================ ================================================ FILE: src/renderer/components/base/Tab.vue ================================================ ================================================ FILE: src/renderer/components/base/VirtualizedList.vue ================================================ ================================================ FILE: src/renderer/components/base/useVirtualizedList.ts ================================================ // import { // computed, // ref, // nextTick, // watch, // onMounted, // onBeforeUnmount, // } from 'vue' // import { scrollTo } from '@common/utils/renderer' // interface ListItem { // item: LX.Music.MusicInfo // top: number // style: { // position: string // left: number // right: number // top: string // height: string // } // index: number // key: string // } // export default (props: { list: LX.Music.MusicInfo[], itemHeight: number }) => { // const dom_scrollContainer = ref(null) // const dom_list = ref(null) // let startIndex = -1 // let endIndex = -1 // let scrollTop = -1 // let cachedList: ListItem[] = [] // let cancelScroll: null | (() => void) = null // let isScrolling = false // let scrollToValue = 0 // const createList = (startIndex: number, endIndex: number) => { // if (startIndex == endIndex) return [] // console.log(startIndex, endIndex) // const cache = cachedList.slice(startIndex, endIndex) // const list = props.list.slice(startIndex, endIndex).map((item, i) => { // if (cache[i]) return cache[i] // const top = (startIndex + i) * props.itemHeight // const index = startIndex + i // return cachedList[index] = { // item, // top, // style: { position: 'absolute', left: 0, right: 0, top: `${top}px`, height: `${props.itemHeight}px` }, // index, // key: item.id, // } // }) // return list // } // // div.list-item(@click="handleListItemClick($event, index)" @contextmenu="handleListItemRightClick($event, index)" // // :class="[{ selected: rightClickSelectedIndex == index }, { active: selectedList.includes(item) }]") // // div.list-item-cell.nobreak.center(:style="{ width: rowWidth.r1 }" style="padding-left: 3px; padding-right: 3px;" :class="$style.noSelect" @click.stop) {{ index + 1 }} // // div.list-item-cell.auto(:style="{ width: rowWidth.r2 }" :aria-label="item.name + (item.meta._qualitys.flac32bit ? ` - ${$t('tag__lossless_24bit')}` : (item.meta._qualitys.ape || item.meta._qualitys.flac || item.meta._qualitys.wav) ? ` - ${$t('tag__lossless')}` : item.meta._qualitys['320k'] ? ` - ${$t('tag__high_quality')}` : '') + (sourceTag ? ` - ${item.source}` : '')") // // span.select {{ item.name }} // // span.badge.badge-theme-primary(:class="[$style.labelQuality, $style.noSelect]" v-if="item.meta._qualitys.flac32bit") {{ $t('tag__lossless_24bit') }} // // span.badge.badge-theme-primary(:class="[$style.labelQuality, $style.noSelect]" v-else-if="item.meta._qualitys.ape || item.meta._qualitys.flac || item.meta._qualitys.wav") {{ $t('tag__lossless') }} // // span.badge.badge-theme-secondary(:class="[$style.labelQuality, $style.noSelect]" v-else-if="item.meta._qualitys['320k']") {{ $t('tag__high_quality') }} // // span.badge.badge-theme-tertiary(:class="[$style.labelQuality, $style.noSelect]" v-if="sourceTag") {{ item.source }} // // div.list-item-cell(:style="{ width: rowWidth.r3 }" :aria-label="item.singer") // // span.select {{ item.singer }} // // div.list-item-cell(:style="{ width: rowWidth.r4 }" :aria-label="item.albumName") // // span.select {{ item.meta.albumName }} // // div.list-item-cell(:style="{ width: rowWidth.r5 }") // // span(:class="[$style.time, $style.noSelect]") {{ item.meta.interval || '--/--' }} // // div.list-item-cell(:style="{ width: rowWidth.r6 }" style="padding-left: 0; padding-right: 0;") // // material-list-buttons(:index="index" :class="$style.btns" // // :remove-btn="false" @btn-click="handleListBtnClick" // // :download-btn="assertApiSupport(item.source)") // const renderListItem = (list: ListItem) => { // const dom_listItem = document.createElement('div') // dom_listItem.className = 'list-item' // } // const renderList = (list: ListItem[], type?: 'up' | 'down') => { // if (!list.length) return // console.log(list) // const dom = document.createDocumentFragment() // for (const item of list) { // dom.appendChild(renderListItem(item)) // } // switch (type) { // case 'up': // break // case 'down': // break // default: // // console.log() // break // } // } // const updateView = (force = false, currentScrollTop = dom_scrollContainer.value.scrollTop) => { // // const currentScrollTop = this.$refs.dom_scrollContainer.scrollTop // const itemHeight = props.itemHeight // const currentStartIndex = Math.floor(currentScrollTop / itemHeight) // const scrollContainerHeight = dom_scrollContainer.value.clientHeight // const currentEndIndex = currentStartIndex + Math.ceil(scrollContainerHeight / itemHeight) // const continuous = currentStartIndex <= endIndex && currentEndIndex >= startIndex // const currentStartRenderIndex = Math.max(currentStartIndex, 0) // const currentEndRenderIndex = currentEndIndex + 1 // // console.log(continuous, currentStartIndex, endIndex, currentEndIndex, startIndex) // // debugger // if (!force && continuous) { // // if (Math.abs(currentScrollTop - this.scrollTop) < this.itemHeight * 0.6) return // // console.log('update') // if (currentScrollTop > scrollTop) { // scroll down // console.log('scroll down') // renderList(createList(endIndex + 1, currentEndRenderIndex)) // // // views.value.push(...list.slice(list.indexOf(views.value[views.value.length - 1]) + 1)) // // // // if (this.views.length > 100) { // // // nextTick(() => { // // // views.value.splice(0, views.value.indexOf(list[0])) // // // }) // // // } // } else if (currentScrollTop < scrollTop) { // scroll up // console.log('scroll up') // renderList(createList(currentStartRenderIndex, startIndex)) // // views.value = createList(currentStartRenderIndex, currentEndRenderIndex) // } else return // // if (currentScrollTop == scrollTop && endIndex >= currentEndIndex) return // // views.value = createList(currentStartRenderIndex, currentEndRenderIndex) // } else { // renderList(createList(currentStartRenderIndex, currentEndRenderIndex)) // } // startIndex = currentStartIndex // endIndex = currentEndIndex // scrollTop = currentScrollTop // } // const onScroll = event => { // const currentScrollTop = dom_scrollContainer.value.scrollTop // if (Math.abs(currentScrollTop - scrollTop) > props.itemHeight * 0.6) { // updateView(false, currentScrollTop) // } // emit('scroll', event) // } // const scrollTo = async(scrollTop, animate = false) => { // return new Promise(resolve => { // if (cancelScroll) { // cancelScroll(resolve) // } else { // resolve() // } // }).then(async() => { // return new Promise((resolve, reject) => { // if (animate) { // isScrolling = true // scrollToValue = scrollTop // cancelScroll = handleScroll(dom_scrollContainer.value, scrollTop, 300, () => { // cancelScroll = null // isScrolling = false // resolve() // }, () => { // cancelScroll = null // isScrolling = false // reject('canceled') // }) // } else { // dom_scrollContainer.value.scrollTop = scrollTop // } // }) // }) // } // const scrollToIndex = async(index, offset = 0, animate = false) => { // return scrollTo(Math.max(index * props.itemHeight + offset, 0), animate) // } // const getScrollTop = () => { // return isScrolling ? scrollToValue : dom_scrollContainer.value.scrollTop // } // const handleResize = () => { // setTimeout(updateView) // } // const contentStyle = computed(() => ({ // display: 'block', // height: props.list.length * props.itemHeight + 'px', // })) // const handleReset = list => { // cachedList = Array(list.length) // startIndex = -1 // endIndex = -1 // void nextTick(() => { // updateView(true) // }) // } // watch(() => props.itemHeight, () => { // handleReset(props.list) // }) // watch(() => props.list, (list) => { // handleReset(list) // }, { // deep: true, // }) // onMounted(() => { // dom_scrollContainer.value!.addEventListener('scroll', onScroll, false) // cachedList = Array(props.list.length) // startIndex = -1 // endIndex = -1 // updateView(true) // window.addEventListener('resize', handleResize) // }) // onBeforeUnmount(() => { // dom_scrollContainer.value!.removeEventListener('scroll', onScroll) // window.removeEventListener('resize', handleResize) // if (cancelScroll) cancelScroll() // }) // return { // dom_scrollContainer, // dom_list, // contentStyle, // scrollTo, // scrollToIndex, // getScrollTop, // } // } export {} ================================================ FILE: src/renderer/components/common/AudioVisualizer.vue ================================================ ================================================ FILE: src/renderer/components/common/DownloadModal.vue ================================================ ================================================ FILE: src/renderer/components/common/DownloadMultipleModal.vue ================================================ ================================================ FILE: src/renderer/components/common/ListAddModal.vue ================================================ ================================================ FILE: src/renderer/components/common/ListAddMultipleModal.vue ================================================ ================================================ FILE: src/renderer/components/common/PlaybackRateBtn.vue ================================================ ================================================ FILE: src/renderer/components/common/ProgressBar.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/AddConvolutionPresetBtn.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/AddEQPresetBtn.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/AudioConvolution.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/AudioPanner.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/BiquadFilter.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/PitchShifter.vue ================================================ ================================================ FILE: src/renderer/components/common/SoundEffectBtn/index.vue ================================================ ================================================ FILE: src/renderer/components/common/TogglePlayModeBtn.vue ================================================ ================================================ FILE: src/renderer/components/common/VolumeBtn.vue ================================================ ================================================ FILE: src/renderer/components/index.js ================================================ import upperFirst from 'lodash/upperFirst' import camelCase from 'lodash/camelCase' const requireComponent = require.context('./', true, /\.vue$/) const vueFileRxp = /\.vue$/ export default app => { requireComponent.keys().forEach(fileName => { const filePath = fileName.replace(/^\.\//, '') if (!filePath.split('/').every((path, index, arr) => { const char = path.charAt(0) return vueFileRxp.test(path) || char.toUpperCase() !== char || arr[index + 1] == 'index.vue' })) return const componentConfig = requireComponent(fileName) let componentName = upperFirst(camelCase(filePath.replace(/\.\w+$/, ''))) if (componentName.endsWith('Index')) componentName = componentName.replace(/Index$/, '') app.component(componentName, componentConfig.default || componentConfig) }) } ================================================ FILE: src/renderer/components/layout/Aside/ControlBtns.vue ================================================ ================================================ FILE: src/renderer/components/layout/Aside/NavBar.vue ================================================ ================================================ FILE: src/renderer/components/layout/Aside/index.vue ================================================ ================================================ FILE: src/renderer/components/layout/ChangeLogModal.vue ================================================ ================================================ FILE: src/renderer/components/layout/Icons.vue ================================================ ================================================ FILE: src/renderer/components/layout/PactModal.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/ControlBtns.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/FullWidthProgress.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/MiddleWidthProgress.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/MiniWidthProgress.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/PlayProgress.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayBar/index.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/ControlBtnsLeftHeader.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/ControlBtnsRightHeader.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/LyricPlayer.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/PlayBar.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/autoHideMounse.js ================================================ import { debounce } from '@common/utils/common' let isAutoHide = false let isLockedPointer = false // let dom = null let event = null let isMouseDown = false const isControl = dom => { if (!dom || dom === document.body) return false // console.log(dom) if (dom.getAttribute('aria-label') || dom.tagName == 'BUTTON') return true return isControl(dom.parentNode) } const lockPointer = () => { if (!isAutoHide || isMouseDown) return if (event && isControl(document.elementFromPoint(event.clientX, event.clientY))) return document.body.requestPointerLock() isLockedPointer = true } const unLockPointer = () => { if (!isLockedPointer) return document.exitPointerLock() isLockedPointer = false } const startTimeout = debounce(lockPointer, 3000) const handleMouseMove = (_event) => { event = _event startTimeout() unLockPointer() } const handleMouseDown = () => { isMouseDown = true } const handleMouseUp = () => { isMouseDown = false startTimeout() } export const registerAutoHideMounse = () => { if (isAutoHide) return // if (!dom) dom = document.getElementById('root') isAutoHide = true document.body.addEventListener('mousemove', handleMouseMove) document.body.addEventListener('mousedown', handleMouseDown) document.body.addEventListener('mouseup', handleMouseUp) startTimeout() } export const unregisterAutoHideMounse = () => { if (!isAutoHide) return isAutoHide = false // console.log(dom) document.body.removeEventListener('mousemove', handleMouseMove) document.body.removeEventListener('mousedown', handleMouseDown) document.body.removeEventListener('mouseup', handleMouseUp) unLockPointer() } ================================================ FILE: src/renderer/components/layout/PlayDetail/components/ControlBtns.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/components/LyricMenu.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/components/MusicComment/CommentFloor.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/components/MusicComment/index.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/index.vue ================================================ ================================================ FILE: src/renderer/components/layout/PlayDetail/useSelectAllLrc.js ================================================ import { ref, onBeforeUnmount, onMounted } from '@common/utils/vueTools' export default () => { const dom_lrc_select_content = ref() const handle_key_mod_a_down = ({ event }) => { if (event.target.tagName == 'INPUT' || !dom_lrc_select_content.value || document.activeElement != dom_lrc_select_content.value) return event.preventDefault() if (event.repeat) return let selection = window.getSelection() let range = document.createRange() range.selectNodeContents(dom_lrc_select_content.value) selection.removeAllRanges() selection.addRange(range) } onMounted(() => { window.key_event.on('key_mod+a_down', handle_key_mod_a_down) }) onBeforeUnmount(() => { window.key_event.off('key_mod+a_down', handle_key_mod_a_down) }) return dom_lrc_select_content } ================================================ FILE: src/renderer/components/layout/SyncAuthCodeModal.vue ================================================ ================================================ FILE: src/renderer/components/layout/SyncModeModal.vue ================================================ ================================================ FILE: src/renderer/components/layout/Toolbar/ControlBtns.vue ================================================ ================================================ FILE: src/renderer/components/layout/Toolbar/SearchInput.vue ================================================ ================================================ FILE: src/renderer/components/layout/Toolbar/index.vue ================================================ ================================================ FILE: src/renderer/components/layout/UpdateModal.vue ================================================ ================================================ FILE: src/renderer/components/layout/View.vue ================================================ ================================================ FILE: src/renderer/components/material/ListButtons.vue ================================================ ================================================ FILE: src/renderer/components/material/Modal.vue ================================================ ================================================ FILE: src/renderer/components/material/OnlineList/index.vue ================================================ ================================================ FILE: src/renderer/components/material/OnlineList/useList.ts ================================================ import { computed, watch, ref, onBeforeUnmount, type Ref } from '@common/utils/vueTools' import { isFullscreen } from '@renderer/store' import { appSetting } from '@renderer/store/setting' import { getFontSizeWithScreen } from '@renderer/utils' const useKeyEvent = ({ handleSelectAllData, listRef }: { handleSelectAllData: () => void listRef: Ref }) => { const keyEvent = { isShiftDown: false, isModDown: false, } const handle_key_shift_down = () => { keyEvent.isShiftDown ||= true } const handle_key_shift_up = () => { keyEvent.isShiftDown &&= false } const handle_key_mod_down = () => { keyEvent.isModDown ||= true } const handle_key_mod_up = () => { keyEvent.isModDown &&= false } const handle_key_mod_a_down = ({ event }: LX.KeyDownEevent) => { if (!event || (event.target as HTMLElement).tagName == 'INPUT' || document.activeElement != listRef.value?.$el) return event.preventDefault() if (event.repeat) return keyEvent.isModDown = false handleSelectAllData() } onBeforeUnmount(() => { window.key_event.off('key_shift_down', handle_key_shift_down) window.key_event.off('key_shift_up', handle_key_shift_up) window.key_event.off('key_mod_down', handle_key_mod_down) window.key_event.off('key_mod_up', handle_key_mod_up) window.key_event.off('key_mod+a_down', handle_key_mod_a_down) }) window.key_event.on('key_shift_down', handle_key_shift_down) window.key_event.on('key_shift_up', handle_key_shift_up) window.key_event.on('key_mod_down', handle_key_mod_down) window.key_event.on('key_mod_up', handle_key_mod_up) window.key_event.on('key_mod+a_down', handle_key_mod_a_down) return keyEvent } export default ({ props, listRef }: { props: { list: LX.Music.MusicInfoOnline[] } listRef: Ref }) => { const selectedList = ref([]) let lastSelectIndex = -1 const listItemHeight = computed(() => { return Math.ceil((isFullscreen.value ? getFontSizeWithScreen() : appSetting['common.fontSize']) * 2.3) }) const removeAllSelect = () => { selectedList.value = [] } const handleSelectAllData = () => { removeAllSelect() selectedList.value = [...props.list] } const keyEvent = useKeyEvent({ handleSelectAllData, listRef }) const handleSelectData = (clickIndex: number) => { if (keyEvent.isShiftDown) { if (selectedList.value.length) { removeAllSelect() if (lastSelectIndex != clickIndex) { let isNeedReverse = false let _lastSelectIndex = lastSelectIndex if (clickIndex < _lastSelectIndex) { let temp = _lastSelectIndex _lastSelectIndex = clickIndex clickIndex = temp isNeedReverse = true } selectedList.value = props.list.slice(_lastSelectIndex, clickIndex + 1) if (isNeedReverse) selectedList.value.reverse() } } else { selectedList.value.push(props.list[clickIndex]) lastSelectIndex = clickIndex } } else if (keyEvent.isModDown) { lastSelectIndex = clickIndex let item = props.list[clickIndex] let index = selectedList.value.indexOf(item) if (index < 0) { selectedList.value.push(item) } else { selectedList.value.splice(index, 1) } } else if (selectedList.value.length) { removeAllSelect() } } watch(() => props.list, removeAllSelect) return { selectedList, listItemHeight, removeAllSelect, handleSelectData, } } ================================================ FILE: src/renderer/components/material/OnlineList/useMenu.js ================================================ import { computed, ref, reactive, nextTick } from '@common/utils/vueTools' import musicSdk from '@renderer/utils/musicSdk' import { useI18n } from '@renderer/plugins/i18n' import { hasDislike } from '@renderer/core/dislikeList' export default ({ props, assertApiSupport, emit, handleShowDownloadModal, handlePlayMusic, handlePlayMusicLater, handleSearch, handleShowMusicAddModal, handleOpenMusicDetail, handleDislikeMusic, }) => { const itemMenuControl = reactive({ play: true, addTo: true, playLater: true, download: true, search: true, sourceDetail: true, dislike: true, }) const t = useI18n() const menuLocation = reactive({ x: 0, y: 0 }) const isShowItemMenu = ref(false) const menus = computed(() => { return [ { name: t('list__play'), action: 'play', disabled: !itemMenuControl.play, }, { name: t('list__download'), action: 'download', disabled: !itemMenuControl.download, }, { name: t('list__play_later'), action: 'playLater', disabled: !itemMenuControl.playLater, }, { name: t('list__search'), action: 'search', disabled: !itemMenuControl.search, }, { name: t('list__add_to'), action: 'addTo', disabled: !itemMenuControl.addTo, }, { name: t('list__source_detail'), action: 'sourceDetail', disabled: !itemMenuControl.sourceDetail, }, { name: t('list__dislike'), action: 'dislike', disabled: !itemMenuControl.dislike, }, ] }) const showMenu = (event, musicInfo) => { itemMenuControl.sourceDetail = !!musicSdk[musicInfo.source]?.getMusicDetailPageUrl // this.listMenu.itemMenuControl.play = // this.listMenu.itemMenuControl.playLater = itemMenuControl.download = assertApiSupport(musicInfo.source) itemMenuControl.dislike = !hasDislike(musicInfo) if (props.checkApiSource) { itemMenuControl.playLater = itemMenuControl.play = itemMenuControl.download } menuLocation.x = event.pageX menuLocation.y = event.pageY if (isShowItemMenu.value) return emit('show-menu') nextTick(() => { isShowItemMenu.value = true }) } const hideMenu = () => { isShowItemMenu.value = false } const menuClick = (action, index) => { // console.log(action) hideMenu() if (!action) return switch (action.action) { case 'download': handleShowDownloadModal(index) break case 'play': handlePlayMusic(index) break case 'playLater': handlePlayMusicLater(index) break case 'search': handleSearch(index) break case 'addTo': handleShowMusicAddModal(index) break case 'sourceDetail': handleOpenMusicDetail(index) break case 'dislike': handleDislikeMusic(index) break } } return { menus, menuLocation, isShowItemMenu, showMenu, menuClick, } } ================================================ FILE: src/renderer/components/material/OnlineList/useMusicActions.js ================================================ import { useRouter } from '@common/utils/vueRouter' import musicSdk from '@renderer/utils/musicSdk' import { openUrl } from '@common/utils/electron' import { toOldMusicInfo } from '@renderer/utils' import { addDislikeInfo, hasDislike } from '@renderer/core/dislikeList' import { playNext } from '@renderer/core/player' import { playMusicInfo } from '@renderer/store/player/state' import { dialog } from '@renderer/plugins/Dialog' import { useI18n } from '@renderer/plugins/i18n' export default ({ props }) => { const router = useRouter() const t = useI18n() const handleSearch = index => { const info = props.list[index] router.push({ path: '/search', query: { text: `${info.name} ${info.singer}`, }, }) } const handleOpenMusicDetail = index => { const minfo = props.list[index] const url = musicSdk[minfo.source]?.getMusicDetailPageUrl?.(toOldMusicInfo(minfo)) if (!url) return openUrl(url) } const handleDislikeMusic = async(index) => { const minfo = props.list[index] const confirm = await dialog.confirm({ message: minfo.singer ? t('lists__dislike_music_singer_tip', { name: minfo.name, singer: minfo.singer }) : t('lists__dislike_music_tip', { name: minfo.name }), cancelButtonText: t('cancel_button_text_2'), confirmButtonText: t('confirm_button_text'), }) if (!confirm) return await addDislikeInfo([{ name: minfo.name, singer: minfo.singer }]) if (hasDislike(playMusicInfo.musicInfo)) { playNext(true) } } return { handleSearch, handleOpenMusicDetail, handleDislikeMusic, } } ================================================ FILE: src/renderer/components/material/OnlineList/useMusicAdd.js ================================================ import { ref, nextTick } from '@common/utils/vueTools' export default ({ selectedList, props }) => { const isShowListAdd = ref(false) const isShowListAddMultiple = ref(false) const selectedAddMusicInfo = ref(null) const handleShowMusicAddModal = (index, single) => { if (selectedList.value.length && !single) { isShowListAddMultiple.value = true } else { selectedAddMusicInfo.value = props.list[index] nextTick(() => { isShowListAdd.value = true }) } } return { isShowListAdd, isShowListAddMultiple, selectedAddMusicInfo, handleShowMusicAddModal, } } ================================================ FILE: src/renderer/components/material/OnlineList/useMusicDownload.js ================================================ import { ref, nextTick } from '@common/utils/vueTools' export default ({ selectedList, props }) => { const isShowDownload = ref(false) const isShowDownloadMultiple = ref(false) const musicInfo = ref(null) const handleShowDownloadModal = (index, single) => { if (selectedList.value.length && !single) { isShowDownloadMultiple.value = true } else { musicInfo.value = props.list[index] nextTick(() => { isShowDownload.value = true }) } } return { isShowDownload, isShowDownloadMultiple, selectedDownloadMusicInfo: musicInfo, handleShowDownloadModal, } } ================================================ FILE: src/renderer/components/material/OnlineList/usePlay.ts ================================================ // import { useCommit } from '@common/utils/vueTools' import { defaultList } from '@renderer/store/list/state' import { getListMusics, addListMusics } from '@renderer/store/list/action' import { addTempPlayList } from '@renderer/store/player/action' import { appSetting } from '@renderer/store/setting' import { type Ref } from '@common/utils/vueTools' import { playList } from '@renderer/core/player' import { LIST_IDS } from '@common/constants' export default ({ selectedList, props, removeAllSelect, emit }: { selectedList: Ref props: { list: LX.Music.MusicInfoOnline[] } removeAllSelect: () => void emit: (event: 'show-menu' | 'play-list' | 'togglePage', ...args: any[]) => void }) => { let clickTime = 0 let clickIndex = -1 const handlePlayMusic = async(index: number, single: boolean) => { let targetSong = props.list[index] const defaultListMusics = await getListMusics(defaultList.id) if (selectedList.value.length && !single) { await addListMusics(defaultList.id, [...selectedList.value]) removeAllSelect() } else { await addListMusics(defaultList.id, [targetSong]) } let targetIndex = defaultListMusics.findIndex(s => s.id === targetSong.id) if (targetIndex > -1) { playList(defaultList.id, targetIndex) } } const handlePlayMusicLater = (index: number, single: boolean) => { if (selectedList.value.length && !single) { addTempPlayList(selectedList.value.map(s => ({ listId: LIST_IDS.PLAY_LATER, musicInfo: s }))) removeAllSelect() } else { addTempPlayList([{ listId: LIST_IDS.PLAY_LATER, musicInfo: props.list[index] }]) } } const doubleClickPlay = (index: number) => { if ( window.performance.now() - clickTime > 400 || clickIndex !== index ) { clickTime = window.performance.now() clickIndex = index return } if (appSetting['list.isClickPlayList']) { emit('play-list', index) } else { void handlePlayMusic(index, true) } clickTime = 0 clickIndex = -1 } return { handlePlayMusic, handlePlayMusicLater, doubleClickPlay, } } ================================================ FILE: src/renderer/components/material/Pagination.vue ================================================ ================================================ FILE: src/renderer/components/material/PopupBtn.vue ================================================ ================================================ FILE: src/renderer/components/material/SearchInput.vue ================================================ ================================================ FILE: src/renderer/components/material/SongList.vue ================================================ ================================================ FILE: src/renderer/core/apiSource.ts ================================================ import { apiSource, qualityList, userApi } from '@renderer/store' import { appSetting, setApiSource } from '@renderer/store/setting' import { setUserApi as setUserApiAction } from '@renderer/utils/ipc' import musicSdk from '@renderer/utils/musicSdk' import apiSourceInfo from '@renderer/utils/musicSdk/api-source-info' let prevId = '' export const setUserApi = async(apiId: string) => { if (prevId == apiId) return prevId = apiId if (window.lx.apiInitPromise[1]) { window.lx.apiInitPromise[0] = new Promise(resolve => { window.lx.apiInitPromise[1] = false window.lx.apiInitPromise[2] = (result: boolean) => { window.lx.apiInitPromise[1] = true resolve(result) } }) } if (/^user_api/.test(apiId)) { qualityList.value = {} userApi.status = false userApi.message = 'initing' await setUserApiAction(apiId).then(() => { if (prevId != apiId) return apiSource.value = apiId }).catch(err => { if (prevId != apiId) return if (!window.lx.apiInitPromise[1]) window.lx.apiInitPromise[2](false) console.log(err) let api = apiSourceInfo.find(api => !api.disabled) if (!api) return apiSource.value = api.id if (api.id != appSetting['common.apiSource']) setApiSource(api.id) }) } else { // @ts-expect-error qualityList.value = musicSdk.supportQuality[apiId] ?? {} apiSource.value = apiId void setUserApiAction(apiId) if (!window.lx.apiInitPromise[1]) window.lx.apiInitPromise[2](true) } if (prevId != apiId) return if (apiId != appSetting['common.apiSource']) setApiSource(apiId) } ================================================ FILE: src/renderer/core/dislikeList.ts ================================================ // import { toRaw } from '@common/utils/vueTools' import { DISLIKE_EVENT_NAME } from '@common/ipcNames' import { rendererInvoke, rendererOff, rendererOn } from '@common/rendererIpc' import { action } from '@renderer/store/dislikeList' export const initDislikeInfo = async() => { action.initDislikeInfo(await rendererInvoke(DISLIKE_EVENT_NAME.get_dislike_music_infos)) } export const hasDislike = (info: LX.Music.MusicInfo | LX.Download.ListItem | null) => { if (!info) return false return action.hasDislike(info) } export const addDislikeInfo = async(infos: LX.Dislike.DislikeMusicInfo[]) => { await rendererInvoke(DISLIKE_EVENT_NAME.add_dislike_music_infos, infos) } export const overwirteDislikeInfo = async(rules: string) => { await rendererInvoke(DISLIKE_EVENT_NAME.overwrite_dislike_music_infos, rules) } export const clearDislikeInfo = async() => { await rendererInvoke(DISLIKE_EVENT_NAME.clear_dislike_music_infos) } const noop = () => {} export const registerRemoteDislikeAction = (onListChanged: (listIds: string[]) => void = noop) => { const add_dislike_music_infos = ({ params: datas }: LX.IpcRendererEventParams) => { action.addDislikeInfo(datas) } const overwrite_dislike_music_infos = ({ params: datas }: LX.IpcRendererEventParams) => { action.overwirteDislikeInfo(datas) } const clear_dislike_music_infos = () => { return action.clearDislikeInfo() } rendererOn(DISLIKE_EVENT_NAME.add_dislike_music_infos, add_dislike_music_infos) rendererOn(DISLIKE_EVENT_NAME.overwrite_dislike_music_infos, overwrite_dislike_music_infos) rendererOn(DISLIKE_EVENT_NAME.clear_dislike_music_infos, clear_dislike_music_infos) return () => { rendererOff(DISLIKE_EVENT_NAME.add_dislike_music_infos, add_dislike_music_infos) rendererOff(DISLIKE_EVENT_NAME.overwrite_dislike_music_infos, overwrite_dislike_music_infos) rendererOff(DISLIKE_EVENT_NAME.clear_dislike_music_infos, clear_dislike_music_infos) } } ================================================ FILE: src/renderer/core/globalData.ts ================================================ // import defaultSetting from '@common/defaultSetting' import createWorkers from '@renderer/worker' window.lx = { // appSetting: defaultSetting, isEditingHotKey: false, isPlayedStop: false, appHotKeyConfig: { local: { enable: false, keys: {}, }, global: { enable: false, keys: {}, }, }, songListInfo: { fromName: '', searchKey: '', searchPosition: 0, songlistKey: '', songlistPosition: 0, }, restorePlayInfo: null, worker: createWorkers(), isProd: process.env.NODE_ENV == 'production', rootOffset: window.dt ? 0 : 8, apiInitPromise: [Promise.resolve(false), true, () => {}], } window.lxData = {} window.ELECTRON_DISABLE_SECURITY_WARNINGS = process.env.ELECTRON_DISABLE_SECURITY_WARNINGS ================================================ FILE: src/renderer/core/lyric.ts ================================================ import Lyric from '@common/utils/lyric-font-player' import { getAnalyser, getCurrentTime as getPlayerCurrentTime } from '@renderer/plugins/player' import { lyric, setLines, setOffset, setTempOffset, setText } from '@renderer/store/player/lyric' import { isPlay, musicInfo } from '@renderer/store/player/state' import { setStatusText } from '@renderer/store/player/action' import { markRawList } from '@common/utils/vueTools' import { appSetting } from '@renderer/store/setting' import { onNewDesktopLyricProcess } from '@renderer/utils/ipc' const getCurrentTime = () => { return getPlayerCurrentTime() * 1000 } let lrc: Lyric let desktopLyricPort: Electron.IpcRendererEvent['ports'][0] | null = null const analyserTools: { dataArray: Uint8Array bufferLength: number analyser: AnalyserNode | null sendDataArray: () => void } = { dataArray: new Uint8Array(), bufferLength: 0, analyser: null, sendDataArray() { if (this.analyser == null) { this.analyser = getAnalyser() // console.log(this.analyser) if (!this.analyser) return this.bufferLength = this.analyser.frequencyBinCount } const dataArray = new Uint8Array(this.bufferLength) this.analyser.getByteFrequencyData(dataArray) sendDesktopLyricInfo({ action: 'send_analyser_data_array', data: dataArray, }, [dataArray.buffer]) }, } export const sendDesktopLyricInfo = (info: LX.DesktopLyric.LyricActions, transferList?: Transferable[]) => { if (desktopLyricPort == null) return if (transferList) desktopLyricPort.postMessage(info, transferList) else desktopLyricPort.postMessage(info) } const handleDesktopLyricMessage = (action: LX.DesktopLyric.WinMainActions) => { switch (action) { case 'get_info': sendDesktopLyricInfo({ action: 'set_info', data: { id: musicInfo.id, singer: musicInfo.singer, name: musicInfo.name, album: musicInfo.album, lrc: musicInfo.lrc, tlrc: musicInfo.tlrc, rlrc: musicInfo.rlrc, lxlrc: musicInfo.lxlrc, // pic: musicInfo.pic, isPlay: isPlay.value, line: lyric.line, played_time: getCurrentTime(), }, }) break case 'get_status': sendDesktopLyricInfo({ action: 'set_status', data: { isPlay: isPlay.value, line: lyric.line, played_time: getCurrentTime(), }, }) break case 'get_analyser_data_array': analyserTools.sendDataArray() break default: break } } export const init = () => { lrc = new Lyric({ shadowContent: false, onPlay(line, text) { setText(text, Math.max(line, 0)) setStatusText(text) window.app_event.lyricLinePlay(text, line) // console.log(line, text) }, onSetLyric(lines, offset) { // listening lyrics seting event // console.log(lines) // lines is array of all lyric text setLines(markRawList([...lines])) setText(lines[0] ?? '', 0) setOffset(offset) // 歌词延迟 setTempOffset(0) // 重置临时延迟 }, onUpdateLyric(lines) { setLines(markRawList([...lines])) setText(lines[0] ?? '', 0) }, rate: appSetting['player.playbackRate'], // offset: 80, }) onNewDesktopLyricProcess(({ event }) => { console.log('onNewDesktopLyricProcess') const [port] = event.ports desktopLyricPort = port port.onmessage = ({ data }) => { handleDesktopLyricMessage(data.action) // The event data can be any serializable object (and the event could even // carry other MessagePorts with it!) // const result = doWork(event.data) // port.postMessage(result) } port.onmessageerror = (event) => { console.log('onmessageerror', event) } }) } export const setLyricOffset = (offset: number) => { const tempOffset = offset - lyric.offset setTempOffset(tempOffset) lrc.setOffset(tempOffset) sendDesktopLyricInfo({ action: 'set_offset', data: tempOffset, }) if (isPlay.value) { setTimeout(() => { const time = getCurrentTime() sendDesktopLyricInfo({ action: 'set_play', data: time, }) lrc.play(time) }) } } export const setPlaybackRate = (rate: number) => { lrc.setPlaybackRate(rate) if (isPlay.value) { setTimeout(() => { const time = getCurrentTime() lrc.play(time) }) } } export const setLyric = () => { if (!musicInfo.id) return if (musicInfo.lrc) { const extendedLyrics = [] if (appSetting['player.isShowLyricRoma'] && musicInfo.rlrc) extendedLyrics.push(musicInfo.rlrc) if (appSetting['player.isShowLyricTranslation'] && musicInfo.tlrc) extendedLyrics.push(musicInfo.tlrc) if (appSetting['player.isSwapLyricTranslationAndRoma']) extendedLyrics.reverse() lrc.setLyric( appSetting['player.isPlayLxlrc'] && musicInfo.lxlrc ? musicInfo.lxlrc : musicInfo.lrc, extendedLyrics, ) sendDesktopLyricInfo({ action: 'set_lyric', data: { lrc: musicInfo.lrc, tlrc: musicInfo.tlrc, rlrc: musicInfo.rlrc, lxlrc: musicInfo.lxlrc, }, }) } if (isPlay.value) { setTimeout(() => { const time = getCurrentTime() sendDesktopLyricInfo({ action: 'set_play', data: time }) lrc.play(time) }) } } export const setDisabledAutoPause = (disabledAutoPause: boolean) => { lrc.setDisabledAutoPause(disabledAutoPause) } let sources = new Map() let prevDisabled = false export const setDisableAutoPauseBySource = (disabled: boolean, source: string) => { sources.set(source, disabled) const currentDisabled = Array.from(sources.values()).some(e => e) if (prevDisabled == currentDisabled) return prevDisabled = currentDisabled setDisabledAutoPause(currentDisabled) } export const play = () => { // if (!musicInfo.lrc) return const currentTime = getCurrentTime() lrc.play(currentTime) sendDesktopLyricInfo({ action: 'set_play', data: currentTime }) } export const pause = () => { lrc.pause() sendDesktopLyricInfo({ action: 'set_pause' }) } export const stop = () => { lrc.setLyric('') sendDesktopLyricInfo({ action: 'set_stop' }) // setLines([]) setText('', 0) } export const sendInfo = () => { sendDesktopLyricInfo({ action: 'set_info', data: { id: musicInfo.id, singer: musicInfo.singer, name: musicInfo.name, album: musicInfo.album, lrc: musicInfo.lrc, tlrc: musicInfo.tlrc, rlrc: musicInfo.rlrc, lxlrc: musicInfo.lxlrc, // pic: musicInfo.pic, isPlay: isPlay.value, line: lyric.line, played_time: getCurrentTime(), }, }) } ================================================ FILE: src/renderer/core/music/download.ts ================================================ import { getDownloadFilePath } from '@renderer/utils/music' import { getMusicUrl as getOnlineMusicUrl, getPicUrl as getOnlinePicUrl, getLyricInfo as getOnlineLyricInfo, } from './online' import { buildLyricInfo, getCachedLyricInfo } from './utils' import { buildSavePath } from '@renderer/store/download/utils' export const getMusicUrl = async({ musicInfo, isRefresh, allowToggleSource = true, onToggleSource = () => {} }: { musicInfo: LX.Download.ListItem isRefresh: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void allowToggleSource?: boolean }): Promise => { if (!isRefresh) { const path = await getDownloadFilePath(musicInfo, buildSavePath(musicInfo)) if (path) return path } return getOnlineMusicUrl({ musicInfo: musicInfo.metadata.musicInfo, isRefresh, onToggleSource, allowToggleSource }) } export const getPicUrl = async({ musicInfo, isRefresh, listId, onToggleSource = () => {} }: { musicInfo: LX.Download.ListItem isRefresh: boolean listId?: string | null onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const path = await getDownloadFilePath(musicInfo, buildSavePath(musicInfo)) if (path) { const pic = await window.lx.worker.main.getMusicFilePic(path) if (pic) return pic } const onlineMusicInfo = musicInfo.metadata.musicInfo if (onlineMusicInfo.meta.picUrl) return onlineMusicInfo.meta.picUrl } return getOnlinePicUrl({ musicInfo: musicInfo.metadata.musicInfo, isRefresh, onToggleSource }).then((url) => { // TODO: when listId required save url (update downloadInfo) return url }) } export const getLyricInfo = async({ musicInfo, isRefresh, onToggleSource = () => {} }: { musicInfo: LX.Download.ListItem isRefresh: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const lyricInfo = await getCachedLyricInfo(musicInfo.metadata.musicInfo) if (lyricInfo) return buildLyricInfo(lyricInfo) } return getOnlineLyricInfo({ musicInfo: musicInfo.metadata.musicInfo, isRefresh, onToggleSource, }).catch(async() => { // 尝试读取文件内歌词 const path = await getDownloadFilePath(musicInfo, buildSavePath(musicInfo)) if (path) { const rawlrcInfo = await window.lx.worker.main.getMusicFileLyric(path) if (rawlrcInfo) return buildLyricInfo(rawlrcInfo) } throw new Error('failed') }) } ================================================ FILE: src/renderer/core/music/index.ts ================================================ // if (targetSong.key) { // 如果是已下载的歌曲 // const filePath = path.join(appSetting['download.savePath'], targetSong.metadata.fileName) // // console.log(filePath) import { getMusicUrl as getOnlineMusicUrl, getPicUrl as getOnlinePicUrl, getLyricInfo as getOnlineLyricInfo, } from './online' import { getMusicUrl as getDownloadMusicUrl, getPicUrl as getDownloadPicUrl, getLyricInfo as getDownloadLyricInfo, } from './download' import { getMusicUrl as getLocalMusicUrl, getPicUrl as getLocalPicUrl, getLyricInfo as getLocalLyricInfo, } from './local' export const getMusicUrl = async({ musicInfo, quality, isRefresh = false, onToggleSource, allowToggleSource, }: { musicInfo: LX.Music.MusicInfo | LX.Download.ListItem isRefresh?: boolean quality?: LX.Quality onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void allowToggleSource?: boolean }): Promise => { if ('progress' in musicInfo) { return getDownloadMusicUrl({ musicInfo, isRefresh, onToggleSource, allowToggleSource }) } else if (musicInfo.source == 'local') { return getLocalMusicUrl({ musicInfo, isRefresh, onToggleSource, allowToggleSource }) } else { return getOnlineMusicUrl({ musicInfo, isRefresh, quality, onToggleSource, allowToggleSource }) } } export const getPicPath = async({ musicInfo, isRefresh = false, listId, onToggleSource, }: { musicInfo: LX.Music.MusicInfo | LX.Download.ListItem listId?: string | null isRefresh?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if ('progress' in musicInfo) { return getDownloadPicUrl({ musicInfo, isRefresh, listId, onToggleSource }) } else if (musicInfo.source == 'local') { return getLocalPicUrl({ musicInfo, isRefresh, listId, onToggleSource }) } else { return getOnlinePicUrl({ musicInfo, isRefresh, listId, onToggleSource }) } } export const getLyricInfo = async({ musicInfo, isRefresh = false, onToggleSource, }: { musicInfo: LX.Music.MusicInfo | LX.Download.ListItem isRefresh?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if ('progress' in musicInfo) { return getDownloadLyricInfo({ musicInfo, isRefresh, onToggleSource }) } else if (musicInfo.source == 'local') { return getLocalLyricInfo({ musicInfo, isRefresh, onToggleSource }) } else { return getOnlineLyricInfo({ musicInfo, isRefresh, onToggleSource }) } } ================================================ FILE: src/renderer/core/music/local.ts ================================================ import { encodePath } from '@common/utils/common' import { updateListMusics } from '@renderer/store/list/action' import { saveLyric, saveMusicUrl } from '@renderer/utils/ipc' import { getLocalFilePath } from '@renderer/utils/music' import { buildLyricInfo, getCachedLyricInfo, getOnlineOtherSourceLyricByLocal, getOnlineOtherSourceLyricInfo, getOnlineOtherSourceMusicUrl, getOnlineOtherSourceMusicUrlByLocal, getOnlineOtherSourcePicByLocal, getOnlineOtherSourcePicUrl, getOtherSource, } from './utils' const getOtherSourceByLocal = async(musicInfo: LX.Music.MusicInfoLocal, handler: (infos: LX.Music.MusicInfoOnline[]) => Promise) => { let result: LX.Music.MusicInfoOnline[] = [] result = await getOtherSource(musicInfo) if (result.length) try { return await handler(result) } catch {} if (musicInfo.name.includes('-')) { const [name, singer] = musicInfo.name.split('-').map(val => val.trim()) result = await getOtherSource({ ...musicInfo, name, singer, }, true) if (result.length) try { return await handler(result) } catch {} result = await getOtherSource({ ...musicInfo, name: singer, singer: name, }, true) if (result.length) try { return await handler(result) } catch {} } let fileName = musicInfo.meta.filePath.split(/\/|\\/).at(-1) if (fileName) { fileName = fileName.substring(0, fileName.lastIndexOf('.')) if (fileName != musicInfo.name) { if (fileName.includes('-')) { const [name, singer] = fileName.split('-').map(val => val.trim()) result = await getOtherSource({ ...musicInfo, name, singer, }, true) if (result.length) try { return await handler(result) } catch {} result = await getOtherSource({ ...musicInfo, name: singer, singer: name, }, true) } else { result = await getOtherSource({ ...musicInfo, name: fileName, singer: '', }, true) } if (result.length) try { return await handler(result) } catch {} } } throw new Error('source not found') } export const getMusicUrl = async({ musicInfo, isRefresh, allowToggleSource = true, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoLocal isRefresh: boolean allowToggleSource?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const path = await getLocalFilePath(musicInfo) if (path) return encodePath(path) } try { return await getOnlineOtherSourceMusicUrlByLocal(musicInfo, isRefresh).then(({ url, quality, isFromCache }) => { if (!isFromCache) void saveMusicUrl(musicInfo, quality, url) return url }) } catch {} if (!allowToggleSource) throw new Error('failed') onToggleSource() return getOtherSourceByLocal(musicInfo, async(otherSource) => { return getOnlineOtherSourceMusicUrl({ musicInfos: [...otherSource], onToggleSource, isRefresh }).then(({ url, quality: targetQuality, musicInfo: targetMusicInfo, isFromCache }) => { // saveLyric(musicInfo, data.lyricInfo) if (!isFromCache) void saveMusicUrl(targetMusicInfo, targetQuality, url) // TODO: save url ? return url }) }) } export const getPicUrl = async({ musicInfo, listId, isRefresh, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoLocal listId?: string | null isRefresh: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const pic = await window.lx.worker.main.getMusicFilePic(musicInfo.meta.filePath) if (pic) return pic if (musicInfo.meta.picUrl) return musicInfo.meta.picUrl } try { return await getOnlineOtherSourcePicByLocal(musicInfo).then(({ url }) => { return url }) } catch {} onToggleSource() return getOtherSourceByLocal(musicInfo, async(otherSource) => { return getOnlineOtherSourcePicUrl({ musicInfos: [...otherSource], onToggleSource, isRefresh }).then(({ url, musicInfo: targetMusicInfo, isFromCache }) => { if (listId) { musicInfo.meta.picUrl = url void updateListMusics([{ id: listId, musicInfo }]) } return url }) }) } export const getLyricInfo = async({ musicInfo, isRefresh, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoLocal isRefresh: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const [lyricInfo, fileLyricInfo] = await Promise.all([getCachedLyricInfo(musicInfo), window.lx.worker.main.getMusicFileLyric(musicInfo.meta.filePath)]) // console.log(lyricInfo, fileLyricInfo) if (lyricInfo?.lyric && lyricInfo.lyric != fileLyricInfo?.lyric) { // 存在已编辑歌词 return buildLyricInfo({ ...lyricInfo, rawlrcInfo: fileLyricInfo ?? lyricInfo.rawlrcInfo }) } if (fileLyricInfo) return buildLyricInfo(fileLyricInfo) if (lyricInfo?.lyric) return buildLyricInfo(lyricInfo) } try { // eslint-disable-next-line @typescript-eslint/promise-function-async return await getOnlineOtherSourceLyricByLocal(musicInfo, isRefresh).then(({ lyricInfo, isFromCache }) => { if (!isFromCache) void saveLyric(musicInfo, lyricInfo) return buildLyricInfo(lyricInfo) }) } catch {} onToggleSource() return getOtherSourceByLocal(musicInfo, async(otherSource) => { return getOnlineOtherSourceLyricInfo({ musicInfos: [...otherSource], onToggleSource, isRefresh }).then(async({ lyricInfo, musicInfo: targetMusicInfo, isFromCache }) => { void saveLyric(musicInfo, lyricInfo) if (isFromCache) return buildLyricInfo(lyricInfo) void saveLyric(targetMusicInfo, lyricInfo) return buildLyricInfo(lyricInfo) }) }) } ================================================ FILE: src/renderer/core/music/online.ts ================================================ import { updateListMusics } from '@renderer/store/list/action' import { appSetting } from '@renderer/store/setting' import { saveLyric, saveMusicUrl, getMusicUrl as getStoreMusicUrl, } from '@renderer/utils/ipc' import { buildLyricInfo, getPlayQuality, handleGetOnlineLyricInfo, handleGetOnlineMusicUrl, handleGetOnlinePicUrl, getCachedLyricInfo, } from './utils' /* export const setMusicUrl = ({ musicInfo, type, url }: { musicInfo: LX.Music.MusicInfo type: LX.Quality url: string }) => { saveMusicUrl(musicInfo, type, url) } export const setPic = (datas: { listId: string musicInfo: LX.Music.MusicInfo url: string }) => { datas.musicInfo.img = datas.url updateMusicInfo({ listId: datas.listId, id: datas.musicInfo.songmid, data: { img: datas.url }, musicInfo: datas.musicInfo, }) } */ export const getMusicUrl = async({ musicInfo, quality, isRefresh, allowToggleSource = true, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoOnline quality?: LX.Quality isRefresh: boolean allowToggleSource?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { // if (!musicInfo._types[type]) { // // 兼容旧版酷我源搜索列表过滤128k音质的bug // if (!(musicInfo.source == 'kw' && type == '128k')) throw new Error('该歌曲没有可播放的音频') // // return Promise.reject(new Error('该歌曲没有可播放的音频')) // } const targetQuality = quality ?? getPlayQuality(appSetting['player.playQuality'], musicInfo) const cachedUrl = await getStoreMusicUrl(musicInfo, targetQuality) if (cachedUrl && !isRefresh) return cachedUrl return handleGetOnlineMusicUrl({ musicInfo, quality, onToggleSource, isRefresh, allowToggleSource }).then(({ url, quality: targetQuality, musicInfo: targetMusicInfo, isFromCache }) => { if (targetMusicInfo.id != musicInfo.id && !isFromCache) void saveMusicUrl(targetMusicInfo, targetQuality, url) void saveMusicUrl(musicInfo, targetQuality, url) return url }) } export const getPicUrl = async({ musicInfo, listId, isRefresh, allowToggleSource = true, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoOnline listId?: string | null isRefresh: boolean allowToggleSource?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (musicInfo.meta.picUrl && !isRefresh) return musicInfo.meta.picUrl return handleGetOnlinePicUrl({ musicInfo, onToggleSource, isRefresh, allowToggleSource }).then(({ url, musicInfo: targetMusicInfo, isFromCache }) => { // picRequest = null if (listId) { musicInfo.meta.picUrl = url void updateListMusics([{ id: listId, musicInfo }]) } // savePic({ musicInfo, url, listId }) return url }) } export const getLyricInfo = async({ musicInfo, isRefresh, allowToggleSource = true, onToggleSource = () => {} }: { musicInfo: LX.Music.MusicInfoOnline isRefresh: boolean allowToggleSource?: boolean onToggleSource?: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise => { if (!isRefresh) { const lyricInfo = await getCachedLyricInfo(musicInfo) if (lyricInfo) return buildLyricInfo(lyricInfo) } // lrcRequest = music[musicInfo.source].getLyric(musicInfo) return handleGetOnlineLyricInfo({ musicInfo, onToggleSource, isRefresh, allowToggleSource }).then(async({ lyricInfo, musicInfo: targetMusicInfo, isFromCache }) => { // lrcRequest = null if (isFromCache) return buildLyricInfo(lyricInfo) if (targetMusicInfo.id == musicInfo.id) void saveLyric(musicInfo, lyricInfo) else void saveLyric(targetMusicInfo, lyricInfo) return buildLyricInfo(lyricInfo) }) } ================================================ FILE: src/renderer/core/music/utils.ts ================================================ import { qualityList } from '@renderer/store' import { assertApiSupport } from '@renderer/store/utils' import musicSdk from '@renderer/utils/musicSdk' import { // getOtherSource as getOtherSourceFromStore, // saveOtherSource as saveOtherSourceFromStore, getMusicUrl as getStoreMusicUrl, getPlayerLyric as getStoreLyric, } from '@renderer/utils/ipc' import { appSetting } from '@renderer/store/setting' import { langS2T, toNewMusicInfo, toOldMusicInfo } from '@renderer/utils' import { requestMsg } from '@renderer/utils/message' import { apis } from '@renderer/utils/musicSdk/api-source' const getOtherSourcePromises = new Map() const otherSourceCache = new Map() export const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/ export const getOtherSource = async(musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, isRefresh = false): Promise => { // if (!isRefresh && musicInfo.id) { // const cachedInfo = await getOtherSourceFromStore(musicInfo.id) // if (cachedInfo.length) return cachedInfo // } if (otherSourceCache.has(musicInfo)) return otherSourceCache.get(musicInfo)! let key: string let searchMusicInfo: { name: string singer: string source: string albumName: string interval: string } if ('progress' in musicInfo) { key = `local_${musicInfo.id}` searchMusicInfo = { name: musicInfo.metadata.musicInfo.name, singer: musicInfo.metadata.musicInfo.singer, source: musicInfo.metadata.musicInfo.source, albumName: musicInfo.metadata.musicInfo.meta.albumName, interval: musicInfo.metadata.musicInfo.interval ?? '', } } else { key = `${musicInfo.source}_${musicInfo.id}` searchMusicInfo = { name: musicInfo.name, singer: musicInfo.singer, source: musicInfo.source, albumName: musicInfo.meta.albumName, interval: musicInfo.interval ?? '', } } if (getOtherSourcePromises.has(key)) return getOtherSourcePromises.get(key) const promise = new Promise((resolve, reject) => { let timeout: null | NodeJS.Timeout = setTimeout(() => { timeout = null reject(new Error('find music timeout')) }, 15_000) musicSdk.findMusic(searchMusicInfo).then((otherSource) => { if (otherSourceCache.size > 10) otherSourceCache.clear() const source = otherSource.map(toNewMusicInfo) as LX.Music.MusicInfoOnline[] otherSourceCache.set(musicInfo, source) resolve(source) }).catch(reject).finally(() => { if (timeout) clearTimeout(timeout) }) }).then((otherSource) => { // if (otherSource.length) void saveOtherSourceFromStore(musicInfo.id, otherSource) return otherSource }).finally(() => { if (getOtherSourcePromises.has(key)) getOtherSourcePromises.delete(key) }) getOtherSourcePromises.set(key, promise) return promise } export const buildLyricInfo = async(lyricInfo: MakeOptional): Promise => { if (!appSetting['player.isS2t']) { // @ts-expect-error if (lyricInfo.rawlrcInfo) return lyricInfo return { ...lyricInfo, rawlrcInfo: { ...lyricInfo } } } if (appSetting['player.isS2t']) { const tasks = [ lyricInfo.lyric ? langS2T(lyricInfo.lyric) : Promise.resolve(''), lyricInfo.tlyric ? langS2T(lyricInfo.tlyric) : Promise.resolve(''), lyricInfo.rlyric ? langS2T(lyricInfo.rlyric) : Promise.resolve(''), lyricInfo.lxlyric ? langS2T(lyricInfo.lxlyric) : Promise.resolve(''), ] if (lyricInfo.rawlrcInfo) { tasks.push(lyricInfo.lyric ? langS2T(lyricInfo.lyric) : Promise.resolve('')) tasks.push(lyricInfo.tlyric ? langS2T(lyricInfo.tlyric) : Promise.resolve('')) tasks.push(lyricInfo.rlyric ? langS2T(lyricInfo.rlyric) : Promise.resolve('')) tasks.push(lyricInfo.lxlyric ? langS2T(lyricInfo.lxlyric) : Promise.resolve('')) } return Promise.all(tasks).then(([lyric, tlyric, rlyric, lxlyric, lyric_raw, tlyric_raw, rlyric_raw, lxlyric_raw]) => { const rawlrcInfo = lyric_raw ? { lyric: lyric_raw, tlyric: tlyric_raw, rlyric: rlyric_raw, lxlyric: lxlyric_raw, } : { lyric, tlyric, rlyric, lxlyric, } return { lyric, tlyric, rlyric, lxlyric, rawlrcInfo, } }) } // @ts-expect-error return lyricInfo.rawlrcInfo ? lyricInfo : { ...lyricInfo, rawlrcInfo: { ...lyricInfo } } } export const getCachedLyricInfo = async(musicInfo: LX.Music.MusicInfo): Promise => { let lrcInfo = await getStoreLyric(musicInfo) // lrcInfo = {} as unknown as LX.Player.LyricInfo if (existTimeExp.test(lrcInfo.lyric)) { if (lrcInfo.tlyric != null) { // if (musicInfo.lrc.startsWith('\ufeff[id:$00000000]')) { // let str = musicInfo.lrc.replace('\ufeff[id:$00000000]\n', '') // commit('setLrc', { musicInfo, lyric: str, tlyric: musicInfo.tlrc, lxlyric: musicInfo.tlrc }) // } else if (musicInfo.lrc.startsWith('[id:$00000000]')) { // let str = musicInfo.lrc.replace('[id:$00000000]\n', '') // commit('setLrc', { musicInfo, lyric: str, tlyric: musicInfo.tlrc, lxlyric: musicInfo.tlrc }) // } if (lrcInfo.lxlyric == null) { switch (musicInfo.source) { // 以下源支持lxlyric 重新获取 case 'kg': case 'kw': case 'mg': case 'wy': case 'tx': break default: return lrcInfo } } else if (lrcInfo.rlyric == null) { // 以下源支持 rlyric 重新获取 if (!['wy', 'kg', 'tx'].includes(musicInfo.source)) return lrcInfo } else return lrcInfo } if (musicInfo.source == 'local') return lrcInfo } return null } export const getOnlineOtherSourceMusicUrlByLocal = async(musicInfo: LX.Music.MusicInfoLocal, isRefresh: boolean): Promise<{ url: string quality: LX.Quality isFromCache: boolean }> => { if (!await window.lx.apiInitPromise[0]) throw new Error('source init failed') const quality = '128k' const cachedUrl = await getStoreMusicUrl(musicInfo, quality) if (cachedUrl && !isRefresh) return { url: cachedUrl, quality, isFromCache: true } let reqPromise try { reqPromise = apis('local').getMusicUrl(toOldMusicInfo(musicInfo), null).promise } catch (err: any) { reqPromise = Promise.reject(err) } return reqPromise.then(({ url }: { url: string }) => { return { url, quality, isFromCache: false } }) } export const getOnlineOtherSourceLyricByLocal = async(musicInfo: LX.Music.MusicInfoLocal, isRefresh: boolean): Promise<{ lyricInfo: LX.Music.LyricInfo isFromCache: boolean }> => { if (!await window.lx.apiInitPromise[0]) throw new Error('source init failed') const lyricInfo = await getCachedLyricInfo(musicInfo) if (lyricInfo && !isRefresh) return { lyricInfo, isFromCache: true } let reqPromise try { reqPromise = apis('local').getLyric(toOldMusicInfo(musicInfo)).promise } catch (err: any) { reqPromise = Promise.reject(err) } return reqPromise.then((lyricInfo: LX.Music.LyricInfo) => { return { lyricInfo, isFromCache: false } }) } export const getOnlineOtherSourcePicByLocal = async(musicInfo: LX.Music.MusicInfoLocal): Promise<{ url: string }> => { if (!await window.lx.apiInitPromise[0]) throw new Error('source init failed') let reqPromise try { reqPromise = apis('local').getPic(toOldMusicInfo(musicInfo)).promise } catch (err: any) { reqPromise = Promise.reject(err) } return reqPromise.then((url: string) => { return { url } }) } export const TRY_QUALITYS_LIST = ['flac24bit', 'flac', '320k'] as const type TryQualityType = typeof TRY_QUALITYS_LIST[number] export const getPlayQuality = (highQuality: LX.Quality, musicInfo: LX.Music.MusicInfoOnline): LX.Quality => { let type: LX.Quality = '128k' if (TRY_QUALITYS_LIST.includes(highQuality as TryQualityType)) { let list = qualityList.value[musicInfo.source] let t = TRY_QUALITYS_LIST .slice(TRY_QUALITYS_LIST.indexOf(highQuality as TryQualityType)) .find(q => musicInfo.meta._qualitys[q] && list?.includes(q)) if (t) type = t } return type } export const getOnlineOtherSourceMusicUrl = async({ musicInfos, quality, onToggleSource, isRefresh, retryedSource = [] }: { musicInfos: LX.Music.MusicInfoOnline[] quality?: LX.Quality onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void isRefresh: boolean retryedSource?: LX.OnlineSource[] }): Promise<{ url: string musicInfo: LX.Music.MusicInfoOnline quality: LX.Quality isFromCache: boolean }> => { if (!await window.lx.apiInitPromise[0]) throw new Error('source init failed') let musicInfo: LX.Music.MusicInfoOnline | null = null let itemQuality: LX.Quality | null = null // eslint-disable-next-line no-cond-assign while (musicInfo = (musicInfos.shift()!)) { if (retryedSource.includes(musicInfo.source)) continue retryedSource.push(musicInfo.source) if (!assertApiSupport(musicInfo.source)) continue itemQuality = quality ?? getPlayQuality(appSetting['player.playQuality'], musicInfo) if (!musicInfo.meta._qualitys[itemQuality]) continue console.log('try toggle to: ', musicInfo.source, musicInfo.name, musicInfo.singer, musicInfo.interval) onToggleSource(musicInfo) break } if (!musicInfo || !itemQuality) throw new Error(window.i18n.t('toggle_source_failed')) const cachedUrl = await getStoreMusicUrl(musicInfo, itemQuality) if (cachedUrl && !isRefresh) return { url: cachedUrl, musicInfo, quality: itemQuality, isFromCache: true } let reqPromise try { reqPromise = musicSdk[musicInfo.source].getMusicUrl(toOldMusicInfo(musicInfo), itemQuality).promise } catch (err: any) { reqPromise = Promise.reject(err) } // retryedSource.includes(musicInfo.source) // eslint-disable-next-line @typescript-eslint/promise-function-async return reqPromise.then(({ url, type }: { url: string, type: LX.Quality }) => { return { musicInfo, url, quality: type, isFromCache: false } // eslint-disable-next-line @typescript-eslint/promise-function-async }).catch((err: any) => { if (err.message == requestMsg.tooManyRequests) throw err console.log(err) return getOnlineOtherSourceMusicUrl({ musicInfos, quality, onToggleSource, isRefresh, retryedSource }) }) } /** * 获取在线音乐URL */ export const handleGetOnlineMusicUrl = async({ musicInfo, quality, onToggleSource, isRefresh, allowToggleSource }: { musicInfo: LX.Music.MusicInfoOnline quality?: LX.Quality isRefresh: boolean allowToggleSource: boolean onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void }): Promise<{ url: string musicInfo: LX.Music.MusicInfoOnline quality: LX.Quality isFromCache: boolean }> => { if (!await window.lx.apiInitPromise[0]) throw new Error('source init failed') // console.log(musicInfo.source) const targetQuality = quality ?? getPlayQuality(appSetting['player.playQuality'], musicInfo) let reqPromise try { reqPromise = musicSdk[musicInfo.source].getMusicUrl(toOldMusicInfo(musicInfo), targetQuality).promise } catch (err: any) { reqPromise = Promise.reject(err) } return reqPromise.then(({ url, type }: { url: string, type: LX.Quality }) => { return { musicInfo, url, quality: type, isFromCache: false } }).catch(async(err: any) => { console.log(err) if (!allowToggleSource || err.message == requestMsg.tooManyRequests) throw err onToggleSource() // eslint-disable-next-line @typescript-eslint/promise-function-async return getOtherSource(musicInfo).then(otherSource => { console.log('find otherSource', otherSource) if (otherSource.length) { return getOnlineOtherSourceMusicUrl({ musicInfos: [...otherSource], onToggleSource, quality, isRefresh, retryedSource: [musicInfo.source], }) } throw err }) }) } export const getOnlineOtherSourcePicUrl = async({ musicInfos, onToggleSource, isRefresh, retryedSource = [] }: { musicInfos: LX.Music.MusicInfoOnline[] onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void isRefresh: boolean retryedSource?: LX.OnlineSource[] }): Promise<{ url: string musicInfo: LX.Music.MusicInfoOnline isFromCache: boolean }> => { let musicInfo: LX.Music.MusicInfoOnline | null = null // eslint-disable-next-line no-cond-assign while (musicInfo = (musicInfos.shift()!)) { if (retryedSource.includes(musicInfo.source)) continue retryedSource.push(musicInfo.source) // if (!assertApiSupport(musicInfo.source)) continue console.log('try toggle to: ', musicInfo.source, musicInfo.name, musicInfo.singer, musicInfo.interval) onToggleSource(musicInfo) break } if (!musicInfo) throw new Error(window.i18n.t('toggle_source_failed')) if (musicInfo.meta.picUrl && !isRefresh) return { musicInfo, url: musicInfo.meta.picUrl, isFromCache: true } let reqPromise try { reqPromise = musicSdk[musicInfo.source].getPic(toOldMusicInfo(musicInfo)) } catch (err: any) { reqPromise = Promise.reject(err) } // retryedSource.includes(musicInfo.source) return reqPromise.then((url: string) => { return { musicInfo, url, isFromCache: false } // eslint-disable-next-line @typescript-eslint/promise-function-async }).catch((err: any) => { console.log(err) return getOnlineOtherSourcePicUrl({ musicInfos, onToggleSource, isRefresh, retryedSource }) }) } /** * 获取在线歌曲封面 */ export const handleGetOnlinePicUrl = async({ musicInfo, isRefresh, onToggleSource, allowToggleSource }: { musicInfo: LX.Music.MusicInfoOnline onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void isRefresh: boolean allowToggleSource: boolean }): Promise<{ url: string musicInfo: LX.Music.MusicInfoOnline isFromCache: boolean }> => { // console.log(musicInfo.source) let reqPromise try { reqPromise = musicSdk[musicInfo.source].getPic(toOldMusicInfo(musicInfo)) } catch (err) { reqPromise = Promise.reject(err) } return reqPromise.then((url: string) => { return { musicInfo, url, isFromCache: false } }).catch(async(err: any) => { console.log(err) if (!allowToggleSource) throw err onToggleSource() // eslint-disable-next-line @typescript-eslint/promise-function-async return getOtherSource(musicInfo).then(otherSource => { console.log('find otherSource', otherSource) if (otherSource.length) { return getOnlineOtherSourcePicUrl({ musicInfos: [...otherSource], onToggleSource, isRefresh, retryedSource: [musicInfo.source], }) } throw err }) }) } export const getOnlineOtherSourceLyricInfo = async({ musicInfos, onToggleSource, isRefresh, retryedSource = [] }: { musicInfos: LX.Music.MusicInfoOnline[] onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void isRefresh: boolean retryedSource?: LX.OnlineSource[] }): Promise<{ lyricInfo: LX.Music.LyricInfo | LX.Player.LyricInfo musicInfo: LX.Music.MusicInfoOnline isFromCache: boolean }> => { let musicInfo: LX.Music.MusicInfoOnline | null = null // eslint-disable-next-line no-cond-assign while (musicInfo = (musicInfos.shift()!)) { if (retryedSource.includes(musicInfo.source)) continue retryedSource.push(musicInfo.source) // if (!assertApiSupport(musicInfo.source)) continue console.log('try toggle to: ', musicInfo.source, musicInfo.name, musicInfo.singer, musicInfo.interval) onToggleSource(musicInfo) break } if (!musicInfo) throw new Error(window.i18n.t('toggle_source_failed')) if (!isRefresh) { const lyricInfo = await getCachedLyricInfo(musicInfo) if (lyricInfo) return { musicInfo, lyricInfo, isFromCache: true } } let reqPromise try { // TODO: remove any type reqPromise = (musicSdk[musicInfo.source].getLyric(toOldMusicInfo(musicInfo)) as any).promise } catch (err: any) { reqPromise = Promise.reject(err) } // retryedSource.includes(musicInfo.source) // eslint-disable-next-line @typescript-eslint/promise-function-async return reqPromise.then((lyricInfo: LX.Music.LyricInfo) => { return existTimeExp.test(lyricInfo.lyric) ? { lyricInfo, musicInfo, isFromCache: false, } : Promise.reject(new Error('failed')) // eslint-disable-next-line @typescript-eslint/promise-function-async }).catch((err: any) => { console.log(err) return getOnlineOtherSourceLyricInfo({ musicInfos, onToggleSource, isRefresh, retryedSource }) }) } /** * 获取在线歌词信息 */ export const handleGetOnlineLyricInfo = async({ musicInfo, onToggleSource, isRefresh, allowToggleSource }: { musicInfo: LX.Music.MusicInfoOnline onToggleSource: (musicInfo?: LX.Music.MusicInfoOnline) => void isRefresh: boolean allowToggleSource: boolean }): Promise<{ musicInfo: LX.Music.MusicInfoOnline lyricInfo: LX.Music.LyricInfo | LX.Player.LyricInfo isFromCache: boolean }> => { // console.log(musicInfo.source) let reqPromise try { // TODO: remove any type reqPromise = (musicSdk[musicInfo.source].getLyric(toOldMusicInfo(musicInfo)) as any).promise } catch (err) { reqPromise = Promise.reject(err) } // eslint-disable-next-line @typescript-eslint/promise-function-async return reqPromise.then((lyricInfo: LX.Music.LyricInfo) => { return existTimeExp.test(lyricInfo.lyric) ? { musicInfo, lyricInfo, isFromCache: false, } : Promise.reject(new Error('failed')) }).catch(async(err: any) => { console.log(err) if (!allowToggleSource) throw err onToggleSource() // eslint-disable-next-line @typescript-eslint/promise-function-async return getOtherSource(musicInfo).then(otherSource => { console.log('find otherSource', otherSource) if (otherSource.length) { return getOnlineOtherSourceLyricInfo({ musicInfos: [...otherSource], onToggleSource, isRefresh, retryedSource: [musicInfo.source], }) } throw err }) }) } ================================================ FILE: src/renderer/core/player/action.ts ================================================ import { isEmpty, setPause, setPlay, setResource, setStop } from '@renderer/plugins/player' import { isPlay, playedList, playInfo, playMusicInfo, tempPlayList, musicInfo as _musicInfo } from '@renderer/store/player/state' import { getList, clearPlayedList, clearTempPlayeList, setPlayMusicInfo, addPlayedList, setMusicInfo, setAllStatus, removeTempPlayList, setPlayListId, removePlayedList, } from '@renderer/store/player/action' import { appSetting } from '@renderer/store/setting' import { getMusicUrl, getPicPath, getLyricInfo } from '../music/index' import { filterList } from './utils' import { requestMsg } from '@renderer/utils/message' import { getRandom } from '@renderer/utils/index' import { addListMusics, removeListMusics } from '@renderer/store/list/action' import { loveList } from '@renderer/store/list/state' import { addDislikeInfo } from '@renderer/core/dislikeList' // import { checkMusicFileAvailable } from '@renderer/utils/music' let gettingUrlId = '' const createGettingUrlId = (musicInfo: LX.Music.MusicInfo | LX.Download.ListItem) => { const tInfo = 'progress' in musicInfo ? musicInfo.metadata.musicInfo.meta.toggleMusicInfo : musicInfo.meta.toggleMusicInfo return `${musicInfo.id}_${tInfo?.id ?? ''}` } const createDelayNextTimeout = (delay: number) => { let timeout: NodeJS.Timeout | null const clearDelayNextTimeout = () => { // console.log(this.timeout) if (timeout) { clearTimeout(timeout) timeout = null } } const addDelayNextTimeout = () => { clearDelayNextTimeout() timeout = setTimeout(() => { timeout = null if (window.lx.isPlayedStop) return console.warn('delay next timeout timeout', delay) void playNext(true) }, delay) } return { clearDelayNextTimeout, addDelayNextTimeout, } } const { addDelayNextTimeout, clearDelayNextTimeout } = createDelayNextTimeout(5000) const { addDelayNextTimeout: addLoadTimeout, clearDelayNextTimeout: clearLoadTimeout } = createDelayNextTimeout(100000) /** * 检查音乐信息是否已更改 */ const diffCurrentMusicInfo = (curMusicInfo: LX.Music.MusicInfo | LX.Download.ListItem): boolean => { // return curMusicInfo !== playMusicInfo.musicInfo || isPlay.value return gettingUrlId != createGettingUrlId(curMusicInfo) || curMusicInfo.id != playMusicInfo.musicInfo?.id || isPlay.value } let cancelDelayRetry: (() => void) | null = null const delayRetry = async(musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, isRefresh = false): Promise => { // if (cancelDelayRetry) cancelDelayRetry() return new Promise((resolve, reject) => { const time = getRandom(2, 6) setAllStatus(window.i18n.t('player__getting_url_delay_retry', { time })) const tiemout = setTimeout(() => { getMusicPlayUrl(musicInfo, isRefresh, true).then((result) => { cancelDelayRetry = null resolve(result) }).catch(async(err: any) => { cancelDelayRetry = null reject(err) }) }, time * 1000) cancelDelayRetry = () => { clearTimeout(tiemout) cancelDelayRetry = null resolve(null) } }) } const getMusicPlayUrl = async(musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, isRefresh = false, isRetryed = false): Promise => { // this.musicInfo.url = await getMusicPlayUrl(targetSong, type) setAllStatus(window.i18n.t('player__getting_url')) if (appSetting['player.autoSkipOnError']) addLoadTimeout() // const type = getPlayType(appSetting['player.highQuality'], musicInfo) let toggleMusicInfo = ('progress' in musicInfo ? musicInfo.metadata.musicInfo : musicInfo).meta.toggleMusicInfo return (toggleMusicInfo ? getMusicUrl({ musicInfo: toggleMusicInfo, isRefresh, allowToggleSource: false, }) : Promise.reject(new Error('not found'))).catch(async() => { return getMusicUrl({ musicInfo, isRefresh, onToggleSource(mInfo) { if (diffCurrentMusicInfo(musicInfo)) return setAllStatus(window.i18n.t('toggle_source_try')) }, }) }).then(url => { if (window.lx.isPlayedStop || diffCurrentMusicInfo(musicInfo)) return null return url // eslint-disable-next-line @typescript-eslint/promise-function-async }).catch(err => { // console.log('err', err.message) if (window.lx.isPlayedStop || diffCurrentMusicInfo(musicInfo) || err.message == requestMsg.cancelRequest) return null if (err.message == requestMsg.tooManyRequests) return delayRetry(musicInfo, isRefresh) if (!isRetryed) return getMusicPlayUrl(musicInfo, isRefresh, true) throw err }) } export const setMusicUrl = (musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, isRefresh?: boolean) => { // if (appSetting['player.autoSkipOnError']) addLoadTimeout() if (!diffCurrentMusicInfo(musicInfo)) return if (cancelDelayRetry) cancelDelayRetry() gettingUrlId = createGettingUrlId(musicInfo) void getMusicPlayUrl(musicInfo, isRefresh).then((url) => { if (!url) return setResource(url) }).catch((err: any) => { console.log(err) setAllStatus(err.message) window.app_event.error() if (appSetting['player.autoSkipOnError']) addDelayNextTimeout() }).finally(() => { if (musicInfo === playMusicInfo.musicInfo) { gettingUrlId = '' clearLoadTimeout() } }) } // 恢复上次播放的状态 const handleRestorePlay = async(restorePlayInfo: LX.Player.SavedPlayInfo) => { const musicInfo = playMusicInfo.musicInfo if (!musicInfo) return setImmediate(() => { if (musicInfo.id != playMusicInfo.musicInfo?.id) return window.app_event.setProgress(appSetting['player.isSavePlayTime'] ? restorePlayInfo.time : 0, restorePlayInfo.maxTime) window.app_event.pause() }) void getPicPath({ musicInfo, listId: playMusicInfo.listId }).then((url: string) => { if (musicInfo.id != playMusicInfo.musicInfo?.id || url == _musicInfo.pic) return setMusicInfo({ pic: url }) window.app_event.picUpdated() }).catch(_ => _) void getLyricInfo({ musicInfo }).then((lyricInfo) => { if (musicInfo.id != playMusicInfo.musicInfo?.id) return setMusicInfo({ lrc: lyricInfo.lyric, tlrc: lyricInfo.tlyric, lxlrc: lyricInfo.lxlyric, rlrc: lyricInfo.rlyric, rawlrc: lyricInfo.rawlrcInfo.lyric, }) window.app_event.lyricUpdated() }).catch((err) => { console.log(err) if (musicInfo.id != playMusicInfo.musicInfo?.id) return setAllStatus(window.i18n.t('lyric__load_error')) }) if (appSetting['player.togglePlayMethod'] == 'random' && !playMusicInfo.isTempPlay) addPlayedList({ ...playMusicInfo as LX.Player.PlayMusicInfo }) } // 处理音乐播放 const handlePlay = () => { window.lx.isPlayedStop &&= false resetRandomNextMusicInfo() if (window.lx.restorePlayInfo) { void handleRestorePlay(window.lx.restorePlayInfo) window.lx.restorePlayInfo = null return } const musicInfo = playMusicInfo.musicInfo if (!musicInfo) return setStop() window.app_event.pause() clearDelayNextTimeout() clearLoadTimeout() if (appSetting['player.togglePlayMethod'] == 'random' && !playMusicInfo.isTempPlay) addPlayedList({ ...(playMusicInfo as LX.Player.PlayMusicInfo) }) setMusicUrl(musicInfo) void getPicPath({ musicInfo, listId: playMusicInfo.listId }).then((url: string) => { if (musicInfo.id != playMusicInfo.musicInfo?.id || url == _musicInfo.pic) return setMusicInfo({ pic: url }) window.app_event.picUpdated() }).catch(_ => _) void getLyricInfo({ musicInfo }).then((lyricInfo) => { if (musicInfo.id != playMusicInfo.musicInfo?.id) return setMusicInfo({ lrc: lyricInfo.lyric, tlrc: lyricInfo.tlyric, lxlrc: lyricInfo.lxlyric, rlrc: lyricInfo.rlyric, rawlrc: lyricInfo.rawlrcInfo.lyric, }) window.app_event.lyricUpdated() }).catch((err) => { console.log(err) if (musicInfo.id != playMusicInfo.musicInfo?.id) return setAllStatus(window.i18n.t('lyric__load_error')) }) } /** * 播放列表内歌曲 * @param listId 列表id * @param id 歌曲id */ export const playListById = (listId: string, id: string) => { const prevListId = playInfo.playerListId setPlayListId(listId) // pause() const musicInfo = getList(listId).find(m => m.id == id) if (!musicInfo) return setPlayMusicInfo(listId, musicInfo) if (appSetting['player.isAutoCleanPlayedList'] || prevListId != listId) clearPlayedList() clearTempPlayeList() handlePlay() } /** * 播放列表内歌曲 * @param listId 列表id * @param index 播放的歌曲位置 */ export const playList = (listId: string, index: number) => { const prevListId = playInfo.playerListId setPlayListId(listId) // pause() setPlayMusicInfo(listId, getList(listId)[index]) if (appSetting['player.isAutoCleanPlayedList'] || prevListId != listId) clearPlayedList() clearTempPlayeList() handlePlay() } const handleToggleStop = () => { stop() setTimeout(() => { setPlayMusicInfo(null, null) }) } const randomNextMusicInfo = { info: null as LX.Player.PlayMusicInfo | null, // index: -1, } export const resetRandomNextMusicInfo = () => { if (randomNextMusicInfo.info) { randomNextMusicInfo.info = null // randomNextMusicInfo.index = -1 } } export const getNextPlayMusicInfo = async(): Promise => { if (tempPlayList.length) { // 如果稍后播放列表存在歌曲则直接播放改列表的歌曲 const playMusicInfo = tempPlayList[0] return playMusicInfo } if (playMusicInfo.musicInfo == null) return null if (randomNextMusicInfo.info) return randomNextMusicInfo.info // console.log(playInfo.playerListId) const currentListId = playInfo.playerListId if (!currentListId) return null const currentList = getList(currentListId) if (playedList.length) { // 移除已播放列表内不存在原列表的歌曲 let currentId: string if (playMusicInfo.isTempPlay) { const musicInfo = currentList[playInfo.playerPlayIndex] if (musicInfo) currentId = musicInfo.id } else { currentId = playMusicInfo.musicInfo.id } // 从已播放列表移除播放列表已删除的歌曲 let index for (index = playedList.findIndex(m => m.musicInfo.id === currentId) + 1; index < playedList.length; index++) { const playMusicInfo = playedList[index] const currentId = playMusicInfo.musicInfo.id if (playMusicInfo.listId == currentListId && !currentList.some(m => m.id === currentId)) { removePlayedList(index) continue } break } if (index < playedList.length) return playedList[index] } // const isCheckFile = findNum > 2 // 针对下载列表,如果超过两次都碰到无效歌曲,则过滤整个列表内的无效歌曲 let { filteredList, playerIndex } = await filterList({ // 过滤已播放歌曲 listId: currentListId, list: currentList, playedList, playerMusicInfo: currentList[playInfo.playerPlayIndex], isNext: true, }) if (!filteredList.length) return null // let currentIndex: number = filteredList.indexOf(currentList[playInfo.playerPlayIndex]) if (playerIndex == -1 && filteredList.length) playerIndex = 0 let nextIndex = playerIndex let togglePlayMethod = appSetting['player.togglePlayMethod'] switch (togglePlayMethod) { case 'listLoop': nextIndex = playerIndex === filteredList.length - 1 ? 0 : playerIndex + 1 break case 'random': nextIndex = getRandom(0, filteredList.length) break case 'list': nextIndex = playerIndex === filteredList.length - 1 ? -1 : playerIndex + 1 break case 'singleLoop': break default: return null } if (nextIndex < 0) return null const nextPlayMusicInfo = { musicInfo: filteredList[nextIndex], listId: currentListId, isTempPlay: false, } if (togglePlayMethod == 'random') { randomNextMusicInfo.info = nextPlayMusicInfo // randomNextMusicInfo.index = nextIndex } return nextPlayMusicInfo } const handlePlayNext = (playMusicInfo: LX.Player.PlayMusicInfo) => { // pause() setPlayMusicInfo(playMusicInfo.listId, playMusicInfo.musicInfo, playMusicInfo.isTempPlay) handlePlay() } /** * 下一曲 * @param isAutoToggle 是否自动切换 * @returns */ export const playNext = async(isAutoToggle = false): Promise => { console.log('skip next', isAutoToggle) if (tempPlayList.length) { // 如果稍后播放列表存在歌曲则直接播放改列表的歌曲 const playMusicInfo = tempPlayList[0] removeTempPlayList(0) handlePlayNext(playMusicInfo) console.log('play temp list') return } if (playMusicInfo.musicInfo == null) { handleToggleStop() console.log('musicInfo empty') return } // console.log(playInfo.playerListId) const currentListId = playInfo.playerListId if (!currentListId) { handleToggleStop() console.log('currentListId empty') return } const currentList = getList(currentListId) if (playedList.length) { // 移除已播放列表内不存在原列表的歌曲 let currentId: string if (playMusicInfo.isTempPlay) { const musicInfo = currentList[playInfo.playerPlayIndex] if (musicInfo) currentId = musicInfo.id } else { currentId = playMusicInfo.musicInfo.id } // 从已播放列表移除播放列表已删除的歌曲 let index for (index = playedList.findIndex(m => m.musicInfo.id === currentId) + 1; index < playedList.length; index++) { const playMusicInfo = playedList[index] const currentId = playMusicInfo.musicInfo.id if (playMusicInfo.listId == currentListId && !currentList.some(m => m.id === currentId)) { removePlayedList(index) continue } break } if (index < playedList.length) { handlePlayNext(playedList[index]) console.log('play played list') return } } if (randomNextMusicInfo.info) { handlePlayNext(randomNextMusicInfo.info) return } // const isCheckFile = findNum > 2 // 针对下载列表,如果超过两次都碰到无效歌曲,则过滤整个列表内的无效歌曲 let { filteredList, playerIndex } = await filterList({ // 过滤已播放歌曲 listId: currentListId, list: currentList, playedList, playerMusicInfo: currentList[playInfo.playerPlayIndex], isNext: true, }) if (!filteredList.length) { handleToggleStop() console.log('filtered list empty') return } // let currentIndex: number = filteredList.indexOf(currentList[playInfo.playerPlayIndex]) if (playerIndex == -1 && filteredList.length) playerIndex = 0 let nextIndex = playerIndex let togglePlayMethod = appSetting['player.togglePlayMethod'] if (!isAutoToggle) { switch (togglePlayMethod) { case 'list': case 'singleLoop': case 'none': togglePlayMethod = 'listLoop' } } switch (togglePlayMethod) { case 'listLoop': nextIndex = playerIndex === filteredList.length - 1 ? 0 : playerIndex + 1 break case 'random': nextIndex = getRandom(0, filteredList.length) break case 'list': nextIndex = playerIndex === filteredList.length - 1 ? -1 : playerIndex + 1 break case 'singleLoop': break default: nextIndex = -1 console.log('stop toggle play', togglePlayMethod, isAutoToggle) return } if (nextIndex < 0) { console.log('next index empty') return } handlePlayNext({ musicInfo: filteredList[nextIndex], listId: currentListId, isTempPlay: false, }) } /** * 上一曲 */ export const playPrev = async(isAutoToggle = false): Promise => { if (playMusicInfo.musicInfo == null) { handleToggleStop() return } const currentListId = playInfo.playerListId if (!currentListId) { handleToggleStop() return } const currentList = getList(currentListId) if (playedList.length) { let currentId: string if (playMusicInfo.isTempPlay) { const musicInfo = currentList[playInfo.playerPlayIndex] if (musicInfo) currentId = musicInfo.id } else { currentId = playMusicInfo.musicInfo.id } // 从已播放列表移除播放列表已删除的歌曲 let index for (index = playedList.findIndex(m => m.musicInfo.id === currentId) - 1; index > -1; index--) { const playMusicInfo = playedList[index] const currentId = playMusicInfo.musicInfo.id if (playMusicInfo.listId == currentListId && !currentList.some(m => m.id === currentId)) { removePlayedList(index) continue } break } if (index > -1) { handlePlayNext(playedList[index]) return } } // const isCheckFile = findNum > 2 let { filteredList, playerIndex } = await filterList({ // 过滤已播放歌曲 listId: currentListId, list: currentList, playedList, playerMusicInfo: currentList[playInfo.playerPlayIndex], isNext: false, }) if (!filteredList.length) { handleToggleStop() return } // let currentIndex = filteredList.indexOf(currentList[playInfo.playerPlayIndex]) if (playerIndex == -1 && filteredList.length) playerIndex = 0 let nextIndex = playerIndex if (!playMusicInfo.isTempPlay) { let togglePlayMethod = appSetting['player.togglePlayMethod'] if (!isAutoToggle) { switch (togglePlayMethod) { case 'list': case 'singleLoop': case 'none': togglePlayMethod = 'listLoop' } } switch (togglePlayMethod) { case 'random': nextIndex = getRandom(0, filteredList.length) break case 'listLoop': case 'list': nextIndex = playerIndex === 0 ? filteredList.length - 1 : playerIndex - 1 break case 'singleLoop': break default: nextIndex = -1 return } if (nextIndex < 0) return } handlePlayNext({ musicInfo: filteredList[nextIndex], listId: currentListId, isTempPlay: false, }) } /** * 恢复播放 */ export const play = () => { window.lx.isPlayedStop &&= false if (playMusicInfo.musicInfo == null) return if (isEmpty()) { if (createGettingUrlId(playMusicInfo.musicInfo) != gettingUrlId) setMusicUrl(playMusicInfo.musicInfo) return } setPlay() } /** * 暂停播放 */ export const pause = () => { setPause() } /** * 停止播放 */ export const stop = () => { setStop() setTimeout(() => { window.app_event.stop() }) } /** * 播放、暂停播放切换 */ export const togglePlay = () => { window.lx.isPlayedStop &&= false if (isPlay.value) { pause() } else { play() } } /** * 收藏当前播放的歌曲 */ export const collectMusic = () => { if (!playMusicInfo.musicInfo) return void addListMusics(loveList.id, ['progress' in playMusicInfo.musicInfo ? playMusicInfo.musicInfo.metadata.musicInfo : playMusicInfo.musicInfo]) } /** * 取消收藏当前播放的歌曲 */ export const uncollectMusic = () => { if (!playMusicInfo.musicInfo) return void removeListMusics({ listId: loveList.id, ids: ['progress' in playMusicInfo.musicInfo ? playMusicInfo.musicInfo.metadata.musicInfo.id : playMusicInfo.musicInfo.id] }) } /** * 不喜欢当前播放的歌曲 */ export const dislikeMusic = async() => { if (!playMusicInfo.musicInfo) return const minfo = 'progress' in playMusicInfo.musicInfo ? playMusicInfo.musicInfo.metadata.musicInfo : playMusicInfo.musicInfo await addDislikeInfo([{ name: minfo.name, singer: minfo.singer }]) await playNext(true) } ================================================ FILE: src/renderer/core/player/index.ts ================================================ export * from './action' export * from './timeoutStop' ================================================ FILE: src/renderer/core/player/timeoutStop.ts ================================================ import { ref, computed, type ComputedRef } from '@common/utils/vueTools' import { isPlay } from '@renderer/store/player/state' import { appSetting } from '@renderer/store/setting' // import { interval, intervalCancel } from '@renderer/utils/ipc' import { pause } from './action' const time = ref(-1) const timeoutTools: { isRunning: boolean // time: number interval: null | number timeout: NodeJS.Timeout | null endTime: number exit: () => void clearTimeout: () => void start: (_time: number) => void } = { isRunning: false, timeout: null, // time: -1, endTime: 0, interval: null, exit() { window.lx.isPlayedStop = true if (!appSetting['player.waitPlayEndStop'] && isPlay.value) { pause() } }, clearTimeout() { if (this.interval) { window.clearInterval(this.interval) this.interval = null } if (this.timeout) { clearTimeout(this.timeout) this.timeout = null } if (!this.isRunning) return // this.time = -1 time.value = -1 this.isRunning = false }, start(_time: number) { this.clearTimeout() // this.time = _time time.value = _time this.isRunning = true this.endTime = performance.now() + _time * 1000 this.interval = window.setInterval(() => { // this.endTime = performance.now() // if (this.time > 0) { // this.time-- // } time.value = Math.max(0, Math.round((this.endTime - performance.now()) / 1000)) }, 1000) this.timeout = setTimeout(() => { this.timeout = null time.value = -1 this.clearTimeout() this.exit() }, _time * 1000) }, } export const startTimeoutStop = (time: number) => { window.lx.isPlayedStop &&= false timeoutTools.start(time) } export const stopTimeoutStop = () => { console.warn('stopTimeoutStop') window.lx.isPlayedStop &&= false timeoutTools.clearTimeout() } const formatTime = (time: number): string => { // let d = parseInt(time / 86400) // d = d ? d.toString() + ':' : '' // time = time % 86400 let h: number | string = Math.trunc(time / 3600) h = h ? h.toString() + ':' : '' time = time % 3600 const m = Math.trunc(time / 60).toString().padStart(2, '0') const s = Math.trunc(time % 60).toString().padStart(2, '0') return `${h}${m}:${s}` } export const useTimeout = () => { const timeLabel: ComputedRef = computed(() => { return time.value > 0 ? formatTime(time.value) : '' }) return { time, timeLabel, } } ================================================ FILE: src/renderer/core/player/utils.ts ================================================ import { toRaw, markRawList } from '@common/utils/vueTools' // import { qualityList } from '@renderer/store' import { clearPlayedList } from '@renderer/store/player/action' import { appSetting } from '@renderer/store/setting' import { dislikeInfo } from '@renderer/store/dislikeList' import { setPowerSaveBlocker as setPowerSaveBlockerRemote } from '@renderer/utils/ipc' // export const getPlayType = (highQuality: boolean, musicInfo: LX.Music.MusicInfo | LX.Download.ListItem): LX.Quality | null => { // if ('progress' in musicInfo || musicInfo.source == 'local') return null // let type: LX.Quality = '128k' // let list = qualityList.value[musicInfo.source] // if (highQuality && musicInfo.meta._qualitys['320k'] && list?.includes('320k')) type = '320k' // return type // } /** * 过滤列表中已播放的歌曲 */ export const filterList = async({ playedList, listId, list, playerMusicInfo, isNext }: { playedList: LX.Player.PlayMusicInfo[] listId: string list: Array playerMusicInfo?: LX.Music.MusicInfo | LX.Download.ListItem isNext: boolean }) => { // if (this.list.listName === null) return // console.log(isCheckFile) let { filteredList, canPlayList, playerIndex } = await window.lx.worker.main.filterMusicList({ listId, list: list.map(m => toRaw(m)), playedList: toRaw(playedList), // savePath: appSetting['download.savePath'], playerMusicInfo: toRaw(playerMusicInfo), dislikeInfo: { names: toRaw(dislikeInfo.names), musicNames: toRaw(dislikeInfo.musicNames), singerNames: toRaw(dislikeInfo.singerNames) }, isNext, }) if (!filteredList.length && playedList.length) { clearPlayedList() return { filteredList: markRawList(canPlayList), playerIndex } } return { filteredList: markRawList(filteredList), playerIndex } } let timeout: NodeJS.Timeout | null = null const clearTimer = () => { if (!timeout) return clearTimeout(timeout) timeout = null } export const setPowerSaveBlocker = (enabled: boolean, force = false) => { if (enabled) { clearTimer() if (!force && !appSetting['player.powerSaveBlocker']) return setPowerSaveBlockerRemote(true) } else if (force) { clearTimer() setPowerSaveBlockerRemote(false) } else { if (timeout) return timeout = setTimeout(() => { setPowerSaveBlockerRemote(false) }, 60_000 * 1.5) } } ================================================ FILE: src/renderer/core/useApp/compositions/usePlaySonglist.ts ================================================ import { playList } from '@renderer/core/player' import { setTempList } from '@renderer/store/list/action' import { tempList, tempListMeta } from '@renderer/store/list/state' import { getListDetail, getListDetailAll } from '@renderer/store/songList/action' const getListPlayIndex = (list: LX.Music.MusicInfoOnline[], index?: number) => { if (index == null) { index = 1 } else { if (index < 1) index = 1 else if (index > list.length) index = list.length } return index - 1 } export default () => { const playSongListDetail = async(source: LX.OnlineSource, link: string, playIndex?: number) => { // console.log(source, link, playIndex) if (link == null) return let isPlayingList = false const id = decodeURIComponent(link) const playListId = `${source}__${decodeURIComponent(link)}` let list = (await getListDetail(id, source, 1)).list if (playIndex == null || list.length > playIndex) { isPlayingList = true await setTempList(playListId, list) playList(tempList.id, getListPlayIndex(list, playIndex)) } list = await getListDetailAll(id, source) if (isPlayingList) { if (tempListMeta.id == id) await setTempList(playListId, list) } else { await setTempList(playListId, list) playList(tempList.id, getListPlayIndex(list, playIndex)) } } return async(source: LX.OnlineSource, link: string, playIndex?: number) => { try { await playSongListDetail(source, link, playIndex) } catch (err) { console.error(err) throw new Error('Get play list failed.') } } } ================================================ FILE: src/renderer/core/useApp/index.ts ================================================ import { checkUpdate, getEnvParams, getViewPrevState, sendInited } from '@renderer/utils/ipc' import { proxy, isFullscreen, themeId } from '@renderer/store' import { appSetting } from '@renderer/store/setting' import useSync from './useSync' import useOpenAPI from './useOpenAPI' import useStatusbarLyric from './useStatusbarLyric' import useUpdate from './useUpdate' import useDataInit from './useDataInit' import useHandleEnvParams from './useHandleEnvParams' import useEventListener from './useEventListener' import useDeeplink from './useDeeplink' import usePlayer from './usePlayer' import useSettingSync from './useSettingSync' import { useRouter } from '@common/utils/vueRouter' import handleListAutoUpdate from './listAutoUpdate' export default () => { // apiSource.value = appSetting['common.apiSource'] proxy.enable = appSetting['network.proxy.enable'] proxy.host = appSetting['network.proxy.host'] proxy.port = appSetting['network.proxy.port'] isFullscreen.value = appSetting['common.startInFullscreen'] themeId.value = appSetting['theme.id'] const router = useRouter() const initSyncService = useSync() const initOpenAPI = useOpenAPI() const initStatusbarLyric = useStatusbarLyric() useEventListener() const initPlayer = usePlayer() const handleEnvParams = useHandleEnvParams() const initData = useDataInit() const initDeeplink = useDeeplink() // const handleListAutoUpdate = useListAutoUpdate() useUpdate() useSettingSync() void getEnvParams().then(envParams => { // 移除代理相关的环境变量设置,防止请求库自动应用它们 // eslint-disable-next-line no-undef // const processEnv = ENVIRONMENT // for (const key of Object.keys(processEnv)) { // // eslint-disable-next-line @typescript-eslint/no-dynamic-delete // if (/^(?:http_proxy|https_proxy|NO_PROXY)$/i.test(key)) delete processEnv[key] // } const envProxy = envParams.cmdParams['proxy-server'] if (envProxy && typeof envProxy == 'string') { const [host, port = ''] = envProxy.split(':') proxy.envProxy = { host, port, } } void getViewPrevState().then(state => { void router.push({ path: state.url, query: state.query }) }) // 初始化我的列表、下载列表等数据 void initData().then(() => { initPlayer() handleEnvParams(envParams) // 处理传入的启动参数 void initDeeplink(envParams) void initSyncService() void initOpenAPI() void initStatusbarLyric() sendInited() handleListAutoUpdate() if (window.lx.isProd && appSetting['common.isAgreePact']) checkUpdate() }) }) } ================================================ FILE: src/renderer/core/useApp/listAutoUpdate.ts ================================================ import { getListUpdateInfo } from '@renderer/utils/data' import { userLists } from '@renderer/store/list/state' import syncSourceList from '@renderer/store/list/syncSourceList' const handleSyncSourceList = async(waitUpdateLists: LX.List.UserListInfo[]) => { if (!waitUpdateLists.length) return const targetListInfo = waitUpdateLists.shift()! // console.log(targetListInfo) try { await syncSourceList(targetListInfo) } catch {} void handleSyncSourceList(waitUpdateLists) } export default () => { void getListUpdateInfo().then(listUpdateInfo => { const waitUpdateLists = Object.entries(listUpdateInfo) .map(([id, info]) => info.isAutoUpdate && userLists.find(l => l.id == id)) .filter(_ => _) as LX.List.UserListInfo[] // for (let i = 2; i > 0; i--) { // void handleSyncSourceList(waitUpdateLists) void handleSyncSourceList(waitUpdateLists) // } }) } ================================================ FILE: src/renderer/core/useApp/useDataInit.ts ================================================ import { getPlayInfo } from '@renderer/utils/ipc' import music from '@renderer/utils/musicSdk' import { log } from '@common/utils' import { getListMusics, getUserLists, registerAction } from '@renderer/store/list/action' import useInitUserApi from './useInitUserApi' import { play, playList } from '@renderer/core/player' import { onBeforeUnmount } from '@common/utils/vueTools' import { appSetting } from '@renderer/store/setting' import { playMusicInfo } from '@renderer/store/player/state' import { initDislikeInfo, registerRemoteDislikeAction } from '@renderer/core/dislikeList' const initPrevPlayInfo = async() => { const info = await getPlayInfo() window.lx.restorePlayInfo = null if (!info?.listId || info.index < 0) return const list = await getListMusics(info.listId) if (!list[info.index]) return window.lx.restorePlayInfo = info playList(info.listId, info.index) if (appSetting['player.startupAutoPlay']) { const musicInfo = playMusicInfo.musicInfo if (!musicInfo) return setTimeout(() => { if (musicInfo.id == playMusicInfo.musicInfo?.id) play() }) } } export default () => { const initUserApi = useInitUserApi() let unregister: null | (() => void) = null let unregisterDislikeEvent: null | (() => void) = null onBeforeUnmount(() => { if (unregister) unregister() if (unregisterDislikeEvent) unregisterDislikeEvent() }) return async() => { await Promise.all([ initUserApi(), // 自定义API ]).catch(err => { log.error(err) }) void music.init() // 初始化音乐sdk unregister = registerAction((ids) => { window.app_event.myListUpdate(ids) }) window.lxData.userLists = await getUserLists() // 获取用户列表 unregisterDislikeEvent = registerRemoteDislikeAction() await initDislikeInfo() // 获取不喜欢列表 await initPrevPlayInfo().catch(err => { log.error(err) }) // 初始化上次的歌曲播放信息 } } ================================================ FILE: src/renderer/core/useApp/useDeeplink/index.ts ================================================ import { onBeforeUnmount } from '@common/utils/vueTools' import { clearEnvParamsDeeplink, focusWindow, onDeeplink } from '@renderer/utils/ipc' import { useDialog } from './utils' import useMusicAction from './useMusicAction' import useSonglistAction from './useSonglistAction' import usePlayerAction from './usePlayerAction' export default () => { let isInited = false const showErrorDialog = useDialog() const handleMusicAction = useMusicAction() const handleSonglistAction = useSonglistAction() const handlePlayerAction = usePlayerAction() const handleLinkAction = async(link: string) => { // console.log(link) const [url, search] = link.split('?') const [type, action, ...paths] = url.replace('lxmusic://', '').split('/') const params: { paths: string[] data?: any [key: string]: any } = { paths: [], } if (search) { for (const param of search.split('&')) { const [key, value] = param.split('=') params[key] = value } if (params.data) params.data = JSON.parse(decodeURIComponent(params.data)) } params.paths = paths.map(p => decodeURIComponent(p)) console.log(params) switch (type) { case 'music': await handleMusicAction(action, params) break case 'songlist': await handleSonglistAction(action, params) break case 'player': await handlePlayerAction(action as any) break default: throw new Error('Unknown type: ' + type) } } const rDeeplink = onDeeplink(async({ params: link }) => { console.log(link) if (!isInited) return clearEnvParamsDeeplink() try { await handleLinkAction(link) } catch (err: any) { showErrorDialog(err.message) focusWindow() } }) onBeforeUnmount(() => { rDeeplink() }) return async(envParams: LX.EnvParams) => { if (envParams.deeplink) { clearEnvParamsDeeplink() try { await handleLinkAction(envParams.deeplink) } catch (err: any) { showErrorDialog(err.message) focusWindow() } } isInited = true } } ================================================ FILE: src/renderer/core/useApp/useDeeplink/useMusicAction.js ================================================ import { markRaw } from '@common/utils/vueTools' import { useRouter } from '@common/utils/vueRouter' import { decodeName } from '@renderer/utils' // import { allList, defaultList, loveList, userLists } from '@renderer/store/list' import { playMusicInfo, isShowPlayerDetail } from '@renderer/store/player/state' import { setShowPlayerDetail, addTempPlayList } from '@renderer/store/player/action' import { dataVerify, qualityFilter, sources } from './utils' import { focusWindow } from '@renderer/utils/ipc' import { playNext } from '@renderer/core/player/action' import { toNewMusicInfo } from '@common/utils/tools' import { LIST_IDS } from '@common/constants' import { getOtherSource } from '@renderer/core/music/utils' const useSearchMusic = () => { const router = useRouter() return ({ paths, data: params }) => { let text let source if (params) { text = dataVerify([ { key: 'keywords', types: ['string', 'number'], max: 128, required: true }, ], params).keywords source = params.source } else { if (!paths.length) throw new Error('Keyword missing') if (paths.length > 1) { text = paths[1] source = paths[0] } else { text = paths[0] } if (text.length > 128) text = text.substring(0, 128) } if (isShowPlayerDetail.value) setShowPlayerDetail(false) const sourceList = [...sources, 'all'] source = sourceList.includes(source) ? source : null setTimeout(() => { router.replace({ path: '/search', query: { text, source, }, }) }, 500) focusWindow() } } const usePlayMusic = () => { const filterInfoByPlayMusic = musicInfo => { switch (musicInfo.source) { case 'kw': musicInfo = dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], required: true, max: 200 }, { key: 'source', types: ['string'], required: true }, { key: 'songmid', types: ['string', 'number'], max: 64, required: true }, { key: 'img', types: ['string'], max: 1024 }, { key: 'albumId', types: ['string', 'number'], max: 64 }, { key: 'interval', types: ['string'], max: 64 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'types', types: ['object'], required: true }, ], musicInfo) break case 'kg': musicInfo = dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], required: true, max: 200 }, { key: 'source', types: ['string'], required: true }, { key: 'songmid', types: ['string', 'number'], max: 64, required: true }, { key: 'img', types: ['string'], max: 1024 }, { key: 'albumId', types: ['string', 'number'], max: 64 }, { key: 'interval', types: ['string'], max: 64 }, { key: '_interval', types: ['number'], max: 64 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'types', types: ['object'], required: true }, { key: 'hash', types: ['string'], required: true, max: 64 }, ], musicInfo) break case 'tx': musicInfo = dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], required: true, max: 200 }, { key: 'source', types: ['string'], required: true }, { key: 'songmid', types: ['string', 'number'], max: 64, required: true }, { key: 'img', types: ['string'], max: 1024 }, { key: 'albumId', types: ['string', 'number'], max: 64 }, { key: 'interval', types: ['string'], max: 64 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'types', types: ['object'], required: true }, { key: 'strMediaMid', types: ['string'], required: true, max: 64 }, { key: 'albumMid', types: ['string'], max: 64 }, ], musicInfo) break case 'wy': musicInfo = dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], required: true, max: 200 }, { key: 'source', types: ['string'], required: true }, { key: 'songmid', types: ['string', 'number'], max: 64, required: true }, { key: 'img', types: ['string'], max: 1024 }, { key: 'albumId', types: ['string', 'number'], max: 64 }, { key: 'interval', types: ['string'], max: 64 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'types', types: ['object'], required: true }, ], musicInfo) break case 'mg': musicInfo = dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], required: true, max: 200 }, { key: 'source', types: ['string'], required: true }, { key: 'songmid', types: ['string', 'number'], max: 64, required: true }, { key: 'img', types: ['string'], max: 1024 }, { key: 'albumId', types: ['string', 'number'], max: 64 }, { key: 'interval', types: ['string'], max: 64 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'types', types: ['object'], required: true }, { key: 'copyrightId', types: ['string', 'number'], required: true, max: 64 }, { key: 'lrcUrl', types: ['string'], max: 1024 }, { key: 'trcUrl', types: ['string'], max: 1024 }, { key: 'mrcUrl', types: ['string'], max: 1024 }, ], musicInfo) break default: throw new Error('Unknown source: ' + musicInfo.source) } musicInfo.types = qualityFilter(musicInfo.source, musicInfo.types) return musicInfo } return ({ data: _musicInfo }) => { _musicInfo = filterInfoByPlayMusic(_musicInfo) let musicInfo = { ..._musicInfo, singer: decodeName(_musicInfo.singer), name: decodeName(_musicInfo.name), albumName: decodeName(_musicInfo.albumName), otherSource: null, _types: {}, typeUrl: {}, } for (const type of musicInfo.types) { musicInfo._types[type.type] = { size: type.size } } musicInfo = toNewMusicInfo(musicInfo) markRaw(musicInfo) const isPlaying = !!playMusicInfo.musicInfo addTempPlayList([{ listId: LIST_IDS.PLAY_LATER, musicInfo, isTop: true }]) if (isPlaying) playNext() } } const useSearchPlayMusic = () => { const verifyInfo = (info) => { return dataVerify([ { key: 'name', types: ['string'], required: true, max: 200 }, { key: 'singer', types: ['string'], max: 200 }, { key: 'albumName', types: ['string'], max: 200 }, { key: 'interval', types: ['string'], max: 64 }, { key: 'playLater', types: ['boolean'] }, ], info) } const searchMusic = async(name, singer, albumName, interval) => { return getOtherSource({ name, singer, interval, meta: { albumName, }, source: 'local', id: `sp_${name}_s${singer}_a${albumName}_i${interval ?? ''}`, }) } return async({ paths, data }) => { // console.log(paths, data) let info if (paths.length) { let name = paths[0].trim() let singer = '' if (name.includes('-')) [name, singer] = name.split('-').map(val => val.trim()) info = { name, singer, } } else info = data info = verifyInfo(info) if (!info.name) return const musicList = await searchMusic(info.name, info.singer || '', info.albumName || '', info.interval || null) if (musicList.length) { console.log('find music:', musicList) const musicInfo = musicList[0] markRaw(musicInfo) const isPlaying = !!playMusicInfo.musicInfo if (info.playLater) { addTempPlayList([{ listId: LIST_IDS.PLAY_LATER, musicInfo }]) } else { addTempPlayList([{ listId: LIST_IDS.PLAY_LATER, musicInfo, isTop: true }]) if (isPlaying) playNext() } } else { console.log('msuic not found:', info) } } } export default () => { const handleSearchMusic = useSearchMusic() const handlePlayMusic = usePlayMusic() const handleSearchPlayMusic = useSearchPlayMusic() return async(action, info) => { switch (action) { case 'search': handleSearchMusic(info) break case 'play': handlePlayMusic(info) break case 'searchPlay': await handleSearchPlayMusic(info) break default: throw new Error('Unknown action: ' + action) } } } ================================================ FILE: src/renderer/core/useApp/useDeeplink/usePlayerAction.ts ================================================ import { collectMusic, dislikeMusic, pause, play, playNext, playPrev, togglePlay, uncollectMusic } from '@renderer/core/player' type Action = 'play' | 'pause' | 'skipNext' | 'skipPrev' | 'togglePlay' | 'collect' | 'uncollect' | 'dislike' export default () => { return async(action: Action) => { switch (action) { case 'play': play() break case 'pause': pause() break case 'skipNext': playNext() break case 'skipPrev': playPrev() break case 'togglePlay': togglePlay() break case 'collect': collectMusic() break case 'uncollect': uncollectMusic() break case 'dislike': dislikeMusic() break default: throw new Error('Unknown action: ' + (action as any ?? '')) } } } ================================================ FILE: src/renderer/core/useApp/useDeeplink/useSonglistAction.js ================================================ import { useRouter, useRoute } from '@common/utils/vueRouter' import { isShowPlayerDetail } from '@renderer/store/player/state' import { setShowPlayerDetail } from '@renderer/store/player/action' import usePlaySonglist from '../compositions/usePlaySonglist' import { focusWindow } from '@renderer/utils/ipc' import { dataVerify, sourceVerify } from './utils' const useOpenSonglist = () => { const router = useRouter() const route = useRoute() const handleOpenSonglist = params => { if (params.id) { router[route.path == '/songList/detail' ? 'replace' : 'push']({ path: '/songList/detail', query: { source: params.source, id: params.id, }, }) } else if (params.url) { router[route.path == '/songList/detail' ? 'replace' : 'push']({ path: '/songList/detail', query: { source: params.source, id: params.url, }, }) } } return ({ paths, data }) => { let songlistInfo = { source: null, id: null, url: null, } if (data) { songlistInfo = data } else { songlistInfo.source = paths[0] songlistInfo.url = paths[1] } sourceVerify(songlistInfo.source) songlistInfo = dataVerify([ { key: 'source', types: ['string'] }, { key: 'id', types: ['string', 'number'], max: 64 }, { key: 'url', types: ['string'], max: 500 }, ], songlistInfo) if (!songlistInfo.id && !songlistInfo.url) throw new Error('id or url missing') if (isShowPlayerDetail.value) setShowPlayerDetail(false) handleOpenSonglist(songlistInfo) focusWindow() } } const usePlaySonglistDetail = () => { const playSongListDetail = usePlaySonglist() return async({ paths, data }) => { let songlistInfo = { source: null, id: null, url: null, index: null, } if (data) { songlistInfo = data } else { songlistInfo.source = paths[0] songlistInfo.url = paths[1] songlistInfo.index = paths[2] if (songlistInfo.index != null) { songlistInfo.index = parseInt(songlistInfo.index) if (Number.isNaN(songlistInfo.index)) delete songlistInfo.index } } sourceVerify(songlistInfo.source) songlistInfo = dataVerify([ { key: 'source', types: ['string'] }, { key: 'id', types: ['string', 'number'], max: 64 }, { key: 'url', types: ['string'], max: 500 }, { key: 'index', types: ['number'], max: 1000000 }, ], songlistInfo) if (!songlistInfo.id && !songlistInfo.url) throw new Error('id or url missing') await playSongListDetail(songlistInfo.source, songlistInfo.id ?? songlistInfo.url, songlistInfo.index) } } export default () => { const handleOpenSonglist = useOpenSonglist() const handlePlaySonglist = usePlaySonglistDetail() return async(action, info) => { switch (action) { case 'open': handleOpenSonglist(info) break case 'play': await handlePlaySonglist(info) break default: throw new Error('Unknown action: ' + action) } } } ================================================ FILE: src/renderer/core/useApp/useDeeplink/utils.js ================================================ import { useI18n } from '@renderer/plugins/i18n' import { dialog } from '@renderer/plugins/Dialog' export const useDialog = () => { const t = useI18n() const errorDialog = message => { dialog({ message: `${t('deep_link__handle_error_tip', { message })}`, confirmButtonText: t('ok'), }) } return errorDialog } export const sources = ['kw', 'kg', 'tx', 'wy', 'mg'] export const sourceVerify = source => { if (!sources.includes(source)) throw new Error('Source no match') } export const qualitys = ['128k', '320k', 'flac', 'flac24bit'] export const qualityFilter = (source, types) => { types = types.filter(({ type }) => qualitys.includes(type)).map(({ type, size, hash }) => { if (size != null && typeof size != 'string') throw new Error(type + ' size type no match') if (source == 'kg' && typeof hash != 'string') throw new Error(type + ' hash type no match') return hash == null ? { type, size } : { type, size, hash } }) if (!types.length) throw new Error('quality no match') return types } export const dataVerify = (rules, data) => { const newData = {} for (const rule of rules) { const val = data[rule.key] if (rule.required && val == null) throw new Error(rule.key + ' missing') if (val != null) { if (rule.types && !rule.types.includes(typeof val)) throw new Error(rule.key + ' type no match') if (rule.max && String(val).length > rule.max) throw new Error(rule.key + ' max length no match') if (rule.min && String(val).length > rule.min) throw new Error(rule.key + ' min length no match') } newData[rule.key] = val } return newData } ================================================ FILE: src/renderer/core/useApp/useEventListener.ts ================================================ import { getFontSizeWithScreen } from '@renderer/utils' import { minWindow, onFocus, onSettingChanged, onThemeChange, openDevTools, quitApp, setFullScreen, showHideWindowToggle, } from '@renderer/utils/ipc' import { isFullscreen, themeId, themeShouldUseDarkColors, } from '@renderer/store' import { appSetting, isShowAnimation, mergeSetting, } from '@renderer/store/setting' import { onBeforeUnmount, watch, } from '@common/utils/vueTools' // import { isLinux, isProd } from '@common/utils' import { openUrl } from '@common/utils/electron' import { HOTKEY_COMMON } from '@common/hotKey' import { applyTheme, getThemes } from '@renderer/store/utils' import { clearDownKeys } from '@renderer/event' const handle_key_down = ({ event, type, key }: LX.KeyDownEevent) => { // console.log(key) if (key != 'escape' || !event || event.repeat || type == 'up' || window.lx.isEditingHotKey || (event.target as HTMLElement)?.classList.contains('ignore-esc') || event.lx_handled) return if ((event.target as HTMLElement).tagName != 'INPUT') { if (isFullscreen.value) { event.lx_handled = true void setFullScreen(false).then(fullscreen => { isFullscreen.value = fullscreen }) } return } (event.target as HTMLInputElement).value = '' ;(event.target as HTMLInputElement).blur() event.lx_handled = true } const handleBodyClick = (event: MouseEvent) => { if ((event?.target as HTMLElement)?.tagName != 'A') return if ((event?.target as HTMLAnchorElement).host == window.location.host) return event.preventDefault() if (/^https?:\/\//.test((event?.target as HTMLAnchorElement).href)) void openUrl((event?.target as HTMLAnchorElement).href) } const handle_open_devtools = () => { openDevTools() } const handle_fullscreen = (event: LX.KeyDownEevent) => { let fullscreen = !isFullscreen.value if (typeof event == 'boolean') { fullscreen = event } else if (event.event?.repeat) return void setFullScreen(fullscreen).then(fullscreen => { isFullscreen.value = fullscreen }) } const handle_selection = (event: LX.KeyDownEevent) => { event.event?.preventDefault() } export default () => { watch(isFullscreen, val => { if (val) { document.documentElement.classList.remove(window.dt ? 'disableTransparent' : 'transparent') document.documentElement.classList.add('fullscreen') document.documentElement.style.fontSize = `${getFontSizeWithScreen(window.screen.width)}px` } else { document.documentElement.classList.remove('fullscreen') document.documentElement.classList.add(window.dt ? 'disableTransparent' : 'transparent') document.documentElement.style.fontSize = `${appSetting['common.fontSize']}px` } }, { immediate: true, }) watch(isShowAnimation, val => { if (val) { if (document.documentElement.classList.contains('disableAnimation')) { document.documentElement.classList.remove('disableAnimation') } } else { if (!document.documentElement.classList.contains('disableAnimation')) { document.documentElement.classList.add('disableAnimation') } } }, { immediate: true, }) const rSetConfig = onSettingChanged(({ params: setting }) => { // console.log(config) mergeSetting(setting) window.app_event.configUpdate(setting) }) const rFocus = onFocus(() => { clearDownKeys() }) const rThemeChange = onThemeChange(({ params: setting }) => { // console.log(setting) if (themeShouldUseDarkColors.value == setting.shouldUseDarkColors) { if (themeId.value == setting.theme.id) return themeId.value = setting.theme.id } else { themeShouldUseDarkColors.value = setting.shouldUseDarkColors if (themeId.value != 'auto') return } getThemes(({ dataPath }) => { applyTheme('auto', appSetting['theme.lightId'], appSetting['theme.darkId'], dataPath) }) }) window.key_event.on(HOTKEY_COMMON.min.action, minWindow) window.key_event.on(HOTKEY_COMMON.hide_toggle.action, showHideWindowToggle) window.key_event.on(HOTKEY_COMMON.close.action, quitApp) window.app_event.on('keyDown', handle_key_down) window.key_event.on('key_mod+f12_down', handle_open_devtools) window.key_event.on('key_f11_down', handle_fullscreen) window.key_event.on('key_mod+a_down', handle_selection) document.body.addEventListener('click', handleBodyClick, true) onBeforeUnmount(() => { window.key_event.off(HOTKEY_COMMON.min.action, minWindow) window.key_event.off(HOTKEY_COMMON.hide_toggle.action, showHideWindowToggle) window.key_event.off(HOTKEY_COMMON.close.action, quitApp) window.app_event.off('keyDown', handle_key_down) window.key_event.off('key_mod+f12_down', handle_open_devtools) window.key_event.off('key_f11_down', handle_fullscreen) window.key_event.off('key_mod+a_down', handle_selection) document.body.removeEventListener('click', handleBodyClick) rSetConfig() rFocus() rThemeChange() }) } ================================================ FILE: src/renderer/core/useApp/useHandleEnvParams.ts ================================================ import { useRouter } from '@common/utils/vueRouter' import { parseUrlParams } from '@common/utils/common' import { defaultList, loveList, userLists } from '@renderer/store/list/state' import { getListMusics } from '@renderer/store/list/action' import usePlaySonglist from './compositions/usePlaySonglist' import { playList } from '@renderer/core/player' const getListPlayIndex = (list: LX.Music.MusicInfo[], indexStr?: string): number => { let index: number if (indexStr == null) { index = 1 } else { index = parseInt(indexStr) if (Number.isNaN(index)) { index = 1 } else { if (index < 1) index = 1 else if (index > list.length) index = list.length } } return index - 1 } const useInitEnvParamSearch = () => { const router = useRouter() return (search?: string) => { if (search == null) return setTimeout(() => { void router.replace({ path: '/search', query: { text: search, }, }) }, 1000) } } const useInitEnvParamPlay = () => { // const setPlayList = useCommit('player', 'setList') const playSongListDetail = usePlaySonglist() return async(playStr?: string) => { if (playStr == null || typeof playStr != 'string') return // -play="source=kw&link=链接、ID" // -play="source=myList&name=名字" // -play="source=myList&name=名字&index=位置" const params = parseUrlParams(playStr) if (params.type != 'songList') return switch (params.source) { case 'myList': if (params.name != null) { let targetList const lists = [defaultList, loveList, ...userLists] for (const list of lists) { if (list.name === params.name) { targetList = list break } } if (!targetList) return playList(targetList.id, getListPlayIndex(await getListMusics(targetList.id), params.index)) } break case 'kw': case 'kg': case 'tx': case 'mg': case 'wy': void playSongListDetail(params.source, params.link, parseInt(params.index)) break } } } export default () => { // 处理启动参数 search const initEnvParamSearch = useInitEnvParamSearch() // 处理启动参数 play const initEnvParamPlay = useInitEnvParamPlay() return (envParams: LX.EnvParams) => { initEnvParamSearch(envParams.cmdParams.search) void initEnvParamPlay(envParams.cmdParams.play) } } ================================================ FILE: src/renderer/core/useApp/useInitUserApi.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { useI18n } from '@renderer/plugins/i18n' import { onUserApiStatus, getUserApiList, sendUserApiRequest as sendUserApiRequestRemote, userApiRequestCancel, onShowUserApiUpdateAlert } from '@renderer/utils/ipc' import { openUrl } from '@common/utils/electron' import { qualityList, userApi } from '@renderer/store' import { appSetting } from '@renderer/store/setting' import { dialog } from '@renderer/plugins/Dialog' import { setUserApi } from '@renderer/core/apiSource' const sendUserApiRequest: typeof sendUserApiRequestRemote = async(data) => { let stop: () => void return new Promise((resolve, reject) => { stop = watch(() => appSetting['common.apiSource'], () => { reject(new Error('source changed')) }) void sendUserApiRequestRemote(data).then(resolve).catch(reject) }).finally(() => { stop() }) } export default () => { const t = useI18n() const rUserApiStatus = onUserApiStatus(({ params: { status, message, apiInfo } }) => { // console.log({ status, message, apiInfo }) userApi.status = status userApi.message = message if (!apiInfo || apiInfo.id !== appSetting['common.apiSource']) return if (status) { if (apiInfo.sources) { let apis: any = {} let qualitys: LX.QualityList = {} for (const [source, { actions, type, qualitys: sourceQualitys }] of Object.entries(apiInfo.sources)) { if (type != 'music') continue apis[source as LX.Source] = {} for (const action of actions) { switch (action) { case 'musicUrl': apis[source].getMusicUrl = (songInfo: LX.Music.MusicInfo, type: LX.Quality) => { const requestKey = `request__${Math.random().toString().substring(2)}` return { canceleFn() { userApiRequestCancel(requestKey) }, promise: sendUserApiRequest({ requestKey, data: { source, action: 'musicUrl', info: { type, musicInfo: songInfo, }, }, // eslint-disable-next-line @typescript-eslint/promise-function-async }).then(res => { // console.log(res) return { type, url: res.data.url } }).catch(async err => { console.log(err.message) return Promise.reject(err) }), } } break case 'lyric': apis[source].getLyric = (songInfo: LX.Music.MusicInfo) => { const requestKey = `request__${Math.random().toString().substring(2)}` return { canceleFn() { userApiRequestCancel(requestKey) }, promise: sendUserApiRequest({ requestKey, data: { source, action: 'lyric', info: { type, musicInfo: songInfo, }, }, // eslint-disable-next-line @typescript-eslint/promise-function-async }).then(res => { // console.log(res) return res.data }).catch(async err => { console.log(err.message) return Promise.reject(err) }), } } break case 'pic': apis[source].getPic = (songInfo: LX.Music.MusicInfo) => { const requestKey = `request__${Math.random().toString().substring(2)}` return { canceleFn() { userApiRequestCancel(requestKey) }, promise: sendUserApiRequest({ requestKey, data: { source, action: 'pic', info: { type, musicInfo: songInfo, }, }, // eslint-disable-next-line @typescript-eslint/promise-function-async }).then(res => { // console.log(res) return res.data }).catch(async err => { console.log(err.message) return Promise.reject(err) }), } } break default: break } } qualitys[source as LX.Source] = sourceQualitys } qualityList.value = qualitys userApi.apis = apis } } else { if (message) { void dialog({ message: `${t('user_api__init_failed_alert', { name: apiInfo.name })}\n${message}`, selection: true, confirmButtonText: t('ok'), }) } } if (!window.lx.apiInitPromise[1]) window.lx.apiInitPromise[2](status) }) const rUserApiShowUpdateAlert = onShowUserApiUpdateAlert(({ params: { name, log, updateUrl } }) => { if (updateUrl) { void dialog({ message: `${t('user_api__update_alert', { name })}\n${log}`, selection: true, showCancel: true, confirmButtonText: t('user_api__update_alert_open_url'), cancelButtonText: t('close'), }).then(confirm => { if (!confirm) return window.setTimeout(() => { void openUrl(updateUrl) }, 300) }) } else { void dialog({ message: `${t('user_api__update_alert', { name })}\n${log}`, selection: true, confirmButtonText: t('ok'), }) } }) onBeforeUnmount(() => { rUserApiStatus() rUserApiShowUpdateAlert() }) return async() => { await setUserApi(appSetting['common.apiSource']) void getUserApiList().then(list => { // console.log(list) // if (![...apiSourceInfo.map(s => s.id), ...list.map(s => s.id)].includes(appSetting['common.apiSource'])) { // console.warn('reset api') // let api = apiSourceInfo.find(api => !api.disabled) // if (api) apiSource.value = api.id // } userApi.list = list }).catch(err => { console.log(err) }) } } ================================================ FILE: src/renderer/core/useApp/useOpenAPI.ts ================================================ import { watch } from '@common/utils/vueTools' import { appSetting } from '@renderer/store/setting' import { sendOpenAPIAction } from '@renderer/utils/ipc' import { openAPI } from '@renderer/store' import { setDisableAutoPauseBySource } from '@renderer/core/lyric' export default () => { const handleEnable = async(enable: boolean, port: string, bindLan: boolean) => { await sendOpenAPIAction({ action: 'enable', data: { enable, port, bindLan, }, }).then((status) => { openAPI.address = status.address openAPI.message = status.message }).catch((error) => { openAPI.address = '' openAPI.message = error.message }).finally(() => { setDisableAutoPauseBySource(!!openAPI.address, 'openAPI') }) } watch(() => appSetting['openAPI.enable'], enable => { void handleEnable(enable, appSetting['openAPI.port'], appSetting['openAPI.bindLan']) }) watch(() => appSetting['openAPI.port'], port => { if (!appSetting['openAPI.enable']) return void handleEnable(appSetting['openAPI.enable'], port, appSetting['openAPI.bindLan']) }) watch(() => appSetting['openAPI.bindLan'], bindLan => { if (!appSetting['openAPI.enable']) return void handleEnable(appSetting['openAPI.enable'], appSetting['openAPI.port'], bindLan) }) return async() => { if (appSetting['openAPI.enable']) { void handleEnable(true, appSetting['openAPI.port'], appSetting['openAPI.bindLan']) } } } ================================================ FILE: src/renderer/core/useApp/usePlayer/index.ts ================================================ import { createAudio, } from '@renderer/plugins/player' import useMediaDevice from './useMediaDevice' import usePlayerEvent from './usePlayerEvent' import usePlayer from './usePlayer' import usePlayStatus from './usePlayStatus' export default () => { createAudio() usePlayerEvent() useMediaDevice() // 初始化音频驱动输出设置 usePlayer() const initPlayStatus = usePlayStatus() return () => { void initPlayStatus() } } ================================================ FILE: src/renderer/core/useApp/usePlayer/useLyric.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { debounce } from '@common/utils/common' // import { setDesktopLyricInfo, onGetDesktopLyricInfo } from '@renderer/utils/ipc' // import { musicInfo } from '@renderer/store/player/state' import { pause, play, setLyric, stop, init, sendInfo, setPlaybackRate, } from '@renderer/core/lyric' import { appSetting } from '@renderer/store/setting' const handleApplyPlaybackRate = debounce(setPlaybackRate, 300) export default () => { init() const setPlayInfo = () => { stop() sendInfo() } watch(() => appSetting['player.isShowLyricTranslation'], setLyric) watch(() => appSetting['player.isShowLyricRoma'], setLyric) watch(() => appSetting['player.isSwapLyricTranslationAndRoma'], setLyric) watch(() => appSetting['player.isPlayLxlrc'], setLyric) window.app_event.on('play', play) window.app_event.on('pause', pause) window.app_event.on('stop', stop) window.app_event.on('error', pause) window.app_event.on('musicToggled', setPlayInfo) window.app_event.on('lyricUpdated', setLyric) window.app_event.on('setPlaybackRate', handleApplyPlaybackRate) onBeforeUnmount(() => { window.app_event.off('play', play) window.app_event.off('pause', pause) window.app_event.off('stop', stop) window.app_event.off('error', pause) window.app_event.off('musicToggled', setPlayInfo) window.app_event.off('lyricUpdated', setLyric) window.app_event.off('setPlaybackRate', handleApplyPlaybackRate) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useMaxOutputChannelCount.ts ================================================ import { watch } from '@common/utils/vueTools' import { setMaxOutputChannelCount } from '@renderer/plugins/player' import { appSetting } from '@renderer/store/setting' export default () => { // console.log(appSetting['player.soundEffect.panner.enable']) setMaxOutputChannelCount(appSetting['player.isMaxOutputChannelCount']) watch(() => appSetting['player.isMaxOutputChannelCount'], (val) => { setMaxOutputChannelCount(val) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useMediaDevice.ts ================================================ import { onBeforeUnmount, watch, } from '@common/utils/vueTools' import { pause } from '@renderer/core/player/action' import { dialog } from '@renderer/plugins/Dialog' import { setMediaDeviceId } from '@renderer/plugins/player' import { isPlay } from '@renderer/store/player/state' import { appSetting, saveMediaDeviceId } from '@renderer/store/setting' const getDevices = async() => { const devices = await navigator.mediaDevices.enumerateDevices() return devices.filter(({ kind }) => kind == 'audiooutput') } let isShowingTipAlert = false export default () => { let prevDeviceLabel: string | null = null let prevDeviceId = '' const getMediaDevice = async(deviceId: string) => { const devices = await getDevices() let device = devices.find(device => device.deviceId === deviceId) if (!device) { deviceId = 'default' device = devices.find(device => device.deviceId === deviceId) } if (!device && !devices.length && !isShowingTipAlert) { isShowingTipAlert = true void dialog({ message: window.i18n.t('media_device__empty_device_tip'), confirmButtonText: window.i18n.t('ok'), }).finally(() => { isShowingTipAlert = false }) } return device ? { label: device.label, deviceId: device.deviceId } : { label: '', deviceId: '' } } const setMediaDevice = async(deviceId: string, label: string) => { prevDeviceLabel = label // console.log(device) setMediaDeviceId(deviceId).then(() => { prevDeviceId = deviceId saveMediaDeviceId(deviceId) }).catch((err: any) => { console.log(err) setMediaDeviceId('default').finally(() => { prevDeviceId = 'default' saveMediaDeviceId('default') }) }) } const handleDeviceChange = (label: string) => { // console.log(device) // console.log(appSetting['player.isMediaDeviceRemovedStopPlay'], isPlay.value, label, prevDeviceLabel) if (label != prevDeviceLabel) { window.app_event.playerDeviceChanged() if (appSetting['player.isMediaDeviceRemovedStopPlay'] && isPlay.value) { window.lx.isPlayedStop = true pause() } } } const handleMediaListChange = async() => { const mediaDeviceId = appSetting['player.mediaDeviceId'] const device = await getMediaDevice(mediaDeviceId) handleDeviceChange(device.label) if (device.deviceId == mediaDeviceId) prevDeviceLabel = device.label else void setMediaDevice(device.deviceId, device.label) } watch(() => appSetting['player.mediaDeviceId'], (id) => { if (prevDeviceId == id) return void getMediaDevice(id).then(async({ deviceId, label }) => setMediaDevice(deviceId, label)) }) void getMediaDevice(appSetting['player.mediaDeviceId']).then(async({ deviceId, label }) => setMediaDevice(deviceId, label)) // eslint-disable-next-line @typescript-eslint/no-misused-promises navigator.mediaDevices.addEventListener('devicechange', handleMediaListChange) onBeforeUnmount(() => { // eslint-disable-next-line @typescript-eslint/no-misused-promises navigator.mediaDevices.removeEventListener('devicechange', handleMediaListChange) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useMediaSessionInfo.ts ================================================ import { onBeforeUnmount } from '@common/utils/vueTools' import { getDuration, getPlaybackRate, getCurrentTime } from '@renderer/plugins/player' import { isPlay, musicInfo, playMusicInfo } from '@renderer/store/player/state' import { playProgress } from '@renderer/store/player/playProgress' import { pause, play, playNext, playPrev, stop } from '@renderer/core/player' export default () => { // 创建一个空白音频以保持对 Media Session 的注册 const emptyAudio = new Audio() emptyAudio.autoplay = false emptyAudio.src = require('@renderer/assets/medias/Silence02s.mp3') emptyAudio.controls = false emptyAudio.preload = 'auto' emptyAudio.onplaying = () => { emptyAudio.pause() } void emptyAudio.play() let prevPicUrl = '' const updateMediaSessionInfo = () => { if (musicInfo.id == null) { navigator.mediaSession.metadata = null return } const mediaMetadata: MediaMetadata = { title: musicInfo.name, artist: musicInfo.singer, album: musicInfo.album, artwork: [], } if (musicInfo.pic) { const pic = new Image() pic.src = prevPicUrl = musicInfo.pic pic.onload = () => { if (prevPicUrl == pic.src) { mediaMetadata.artwork = [{ src: pic.src }] // @ts-expect-error navigator.mediaSession.metadata = new window.MediaMetadata(mediaMetadata) } } } else prevPicUrl = '' // @ts-expect-error navigator.mediaSession.metadata = new window.MediaMetadata(mediaMetadata) } const updatePositionState = (state: { duration?: number position?: number playbackRate?: number } = {}) => { navigator.mediaSession.setPositionState({ duration: state.duration ?? getDuration(), playbackRate: state.playbackRate ?? getPlaybackRate(), position: state.position ?? getCurrentTime(), }) } const setProgress = (time: number) => { window.app_event.setProgress(time) } const setStop = () => { stop() } const handlePlay = () => { navigator.mediaSession.playbackState = 'playing' } const handlePause = () => { navigator.mediaSession.playbackState = 'paused' } const handleStop = () => { navigator.mediaSession.playbackState = 'none' } const handleSetPlayInfo = () => { void emptyAudio.play().finally(() => { updateMediaSessionInfo() updatePositionState({ position: playProgress.nowPlayTime, duration: playProgress.maxPlayTime, }) handlePause() }) } // const registerMediaSessionHandler = () => { navigator.mediaSession.setActionHandler('play', () => { if (isPlay.value || !playMusicInfo) return console.log('play') play() }) navigator.mediaSession.setActionHandler('pause', () => { if (!isPlay.value || !playMusicInfo) return console.log('pause') pause() }) navigator.mediaSession.setActionHandler('stop', () => { console.log('stop') setStop() }) navigator.mediaSession.setActionHandler('seekbackward', details => { console.log('seekbackward') const seekOffset = details.seekOffset ?? 5 setProgress(Math.max(getCurrentTime() - seekOffset, 0)) }) navigator.mediaSession.setActionHandler('seekforward', details => { console.log('seekforward') const seekOffset = details.seekOffset ?? 5 setProgress(Math.min(getCurrentTime() + seekOffset, getDuration())) }) navigator.mediaSession.setActionHandler('seekto', details => { console.log('seekto', details.seekTime) if (details.seekTime == null) return let time = Math.min(details.seekTime, getDuration()) time = Math.max(time, 0) setProgress(time) }) navigator.mediaSession.setActionHandler('previoustrack', () => { console.log('previoustrack') void playPrev() }) navigator.mediaSession.setActionHandler('nexttrack', () => { console.log('nexttrack') void playNext() }) // navigator.mediaSession.setActionHandler('skipad', () => { // console.log('') // }) // } window.app_event.on('playerLoadeddata', updatePositionState) window.app_event.on('playerPlaying', updatePositionState) window.app_event.on('play', handlePlay) window.app_event.on('pause', handlePause) window.app_event.on('stop', handleStop) window.app_event.on('error', handlePause) window.app_event.on('playerEmptied', handleSetPlayInfo) // window.app_event.on('playerLoadstart', handleSetPlayInfo) window.app_event.on('musicToggled', handleSetPlayInfo) window.app_event.on('picUpdated', updateMediaSessionInfo) onBeforeUnmount(() => { window.app_event.off('playerLoadeddata', updatePositionState) window.app_event.off('playerPlaying', updatePositionState) window.app_event.off('play', handlePlay) window.app_event.off('pause', handlePause) window.app_event.off('stop', handleStop) window.app_event.off('error', handlePause) window.app_event.off('playerEmptied', handleSetPlayInfo) // window.app_event.off('playerLoadstart', handleSetPlayInfo) window.app_event.off('musicToggled', handleSetPlayInfo) window.app_event.off('picUpdated', updateMediaSessionInfo) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlayEvent.ts ================================================ import { onBeforeUnmount } from '@common/utils/vueTools' import { useI18n } from '@renderer/plugins/i18n' import { musicInfo, playMusicInfo } from '@renderer/store/player/state' import { setStop, isEmpty } from '@renderer/plugins/player' import { playNext, setMusicUrl } from '@renderer/core/player' import { setAllStatus } from '@renderer/store/player/action' import { appSetting } from '@renderer/store/setting' export default () => { const t = useI18n() let retryNum = 0 let prevTimeoutId: string | null = null let loadingTimeout: NodeJS.Timeout | null = null let delayNextTimeout: NodeJS.Timeout | null = null const startLoadingTimeout = () => { // console.log('start load timeout') clearLoadingTimeout() loadingTimeout = setTimeout(() => { if (window.lx.isPlayedStop) { prevTimeoutId = null setAllStatus('') return } // 如果加载超时,则尝试刷新URL if (prevTimeoutId == musicInfo.id) { prevTimeoutId = null void playNext(true) } else { prevTimeoutId = musicInfo.id if (playMusicInfo.musicInfo) setMusicUrl(playMusicInfo.musicInfo, true) } }, 25000) } const clearLoadingTimeout = () => { if (!loadingTimeout) return // console.log('clear load timeout') clearTimeout(loadingTimeout) loadingTimeout = null } const clearDelayNextTimeout = () => { // console.log(this.delayNextTimeout) if (!delayNextTimeout) return clearTimeout(delayNextTimeout) delayNextTimeout = null } const addDelayNextTimeout = () => { clearDelayNextTimeout() delayNextTimeout = setTimeout(() => { if (window.lx.isPlayedStop) { setAllStatus('') return } void playNext(true) }, 5000) } const handleLoadstart = () => { if (window.lx.isPlayedStop) return if (appSetting['player.autoSkipOnError']) startLoadingTimeout() setAllStatus(t('player__loading')) } const handleLoadeddata = () => { setAllStatus(t('player__loading')) } const handlePlaying = () => { setAllStatus('') clearLoadingTimeout() } const handleEmpied = () => { clearDelayNextTimeout() clearLoadingTimeout() } const handleWating = () => { setAllStatus(t('player__buffering')) } const handleError = (errCode?: number) => { if (!musicInfo.id) return clearLoadingTimeout() if (window.lx.isPlayedStop) return if (!isEmpty()) setStop() if (playMusicInfo.musicInfo && errCode !== 1 && retryNum < 2) { // 若音频URL无效则尝试刷新2次URL // console.log(this.retryNum) retryNum++ setMusicUrl(playMusicInfo.musicInfo, true) setAllStatus(t('player__refresh_url')) return } if (appSetting['player.autoSkipOnError']) { if (document.hidden) { console.warn('error skip to next') void playNext(true) } else { setAllStatus(t('player__error')) setTimeout(addDelayNextTimeout) } } } const handleSetPlayInfo = () => { retryNum = 0 prevTimeoutId = null clearDelayNextTimeout() clearLoadingTimeout() } // const handlePlayedStop = () => { // clearDelayNextTimeout() // clearLoadingTimeout() // } window.app_event.on('playerLoadstart', handleLoadstart) window.app_event.on('playerLoadeddata', handleLoadeddata) window.app_event.on('playerPlaying', handlePlaying) window.app_event.on('playerWaiting', handleWating) window.app_event.on('playerEmptied', handleEmpied) window.app_event.on('playerError', handleError) window.app_event.on('musicToggled', handleSetPlayInfo) onBeforeUnmount(() => { window.app_event.off('playerLoadstart', handleLoadstart) window.app_event.off('playerLoadeddata', handleLoadeddata) window.app_event.off('playerPlaying', handlePlaying) window.app_event.off('playerWaiting', handleWating) window.app_event.off('playerEmptied', handleEmpied) window.app_event.off('playerError', handleError) window.app_event.off('musicToggled', handleSetPlayInfo) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlayProgress.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { formatPlayTime2, getRandom } from '@common/utils/common' import { throttle } from '@common/utils' import { savePlayInfo } from '@renderer/utils/ipc' import { onTimeupdate, getCurrentTime, getDuration, setCurrentTime, onVisibilityChange } from '@renderer/plugins/player' import { playProgress, setNowPlayTime, setMaxplayTime } from '@renderer/store/player/playProgress' import { musicInfo, playMusicInfo, playInfo } from '@renderer/store/player/state' // import { getList } from '@renderer/store/utils' import { appSetting } from '@renderer/store/setting' import { playNext } from '@renderer/core/player' import { updateListMusics } from '@renderer/store/list/action' const delaySavePlayInfo = throttle(savePlayInfo, 2000) export default () => { let restorePlayTime = 0 const mediaBuffer: { timeout: NodeJS.Timeout | null playTime: number } = { timeout: null, playTime: 0, } // const updateMusicInfo = useCommit('list', 'updateMusicInfo') const startBuffering = () => { console.log('start t') if (mediaBuffer.timeout) return mediaBuffer.timeout = setTimeout(() => { mediaBuffer.timeout = null if (window.lx.isPlayedStop) return const currentTime = getCurrentTime() mediaBuffer.playTime ||= currentTime let skipTime = currentTime + getRandom(3, 6) if (skipTime > playProgress.maxPlayTime) skipTime = (playProgress.maxPlayTime - currentTime) / 2 if (skipTime - mediaBuffer.playTime < 1 || playProgress.maxPlayTime - skipTime < 1) { mediaBuffer.playTime = 0 if (appSetting['player.autoSkipOnError']) { console.warn('buffering end') void playNext(true) } return } startBuffering() setCurrentTime(skipTime) console.log(mediaBuffer.playTime) console.log(currentTime) }, 3000) } const clearBufferTimeout = () => { console.log('clear t') if (!mediaBuffer.timeout) return clearTimeout(mediaBuffer.timeout) mediaBuffer.timeout = null mediaBuffer.playTime = 0 } const setProgress = (time: number, maxTime?: number) => { if (!musicInfo.id) return if (maxTime != null) setMaxplayTime(maxTime) console.log('setProgress', time, maxTime) if (time > 0) restorePlayTime = time if (mediaBuffer.playTime) { clearBufferTimeout() mediaBuffer.playTime = time startBuffering() } setNowPlayTime(time) setCurrentTime(time) // if (!isPlay) audio.play() } const handlePause = () => { clearBufferTimeout() } const handleStop = () => { setNowPlayTime(0) setMaxplayTime(0) } const handleError = () => { restorePlayTime ||= getCurrentTime() // 记录出错的播放时间 console.log('handleError') } const handleLoadeddata = () => { setMaxplayTime(getDuration()) if (playMusicInfo.musicInfo && 'source' in playMusicInfo.musicInfo && !playMusicInfo.musicInfo.interval) { // console.log(formatPlayTime2(playProgress.maxPlayTime)) if (playMusicInfo.listId) { void updateListMusics([{ id: playMusicInfo.listId, musicInfo: { ...playMusicInfo.musicInfo, interval: formatPlayTime2(playProgress.maxPlayTime), }, }]) } } } const handlePlaying = () => { console.log('handlePlaying', mediaBuffer.playTime, restorePlayTime) clearBufferTimeout() if (mediaBuffer.playTime) { let playTime = mediaBuffer.playTime mediaBuffer.playTime = 0 setCurrentTime(playTime) } else if (restorePlayTime) { setCurrentTime(restorePlayTime) restorePlayTime = 0 } } const handleWating = () => { startBuffering() } const handleEmpied = () => { mediaBuffer.playTime = 0 clearBufferTimeout() } const handleSetPlayInfo = () => { // restorePlayTime = playProgress.nowPlayTime setCurrentTime(restorePlayTime = playProgress.nowPlayTime) // setMaxplayTime(playProgress.maxPlayTime) handlePause() if (!playMusicInfo.isTempPlay && playMusicInfo.listId) { delaySavePlayInfo({ time: playProgress.nowPlayTime, maxTime: playProgress.maxPlayTime, listId: playMusicInfo.listId, index: playInfo.playIndex, }) } } watch(() => playProgress.nowPlayTime, (newValue, oldValue) => { if (Math.abs(newValue - oldValue) > 2) window.app_event.activePlayProgressTransition() if (appSetting['player.isSavePlayTime'] && !playMusicInfo.isTempPlay) { delaySavePlayInfo({ time: newValue, maxTime: playProgress.maxPlayTime, listId: playMusicInfo.listId as string, index: playInfo.playIndex, }) } }) watch(() => playProgress.maxPlayTime, maxPlayTime => { if (!playMusicInfo.isTempPlay) { delaySavePlayInfo({ time: playProgress.nowPlayTime, maxTime: maxPlayTime, listId: playMusicInfo.listId as string, index: playInfo.playIndex, }) } }) // window.app_event.on('play', handlePlay) window.app_event.on('pause', handlePause) window.app_event.on('stop', handleStop) window.app_event.on('error', handleError) window.app_event.on('setProgress', setProgress) // window.app_event.on(eventPlayerNames.restorePlay, handleRestorePlay) window.app_event.on('playerLoadeddata', handleLoadeddata) window.app_event.on('playerPlaying', handlePlaying) window.app_event.on('playerWaiting', handleWating) window.app_event.on('playerEmptied', handleEmpied) window.app_event.on('musicToggled', handleSetPlayInfo) const rOnTimeupdate = onTimeupdate(() => { setNowPlayTime(getCurrentTime()) }) let currentPlayTime = 0 const rVisibilityChange = onVisibilityChange(() => { if (document.hidden) { currentPlayTime = playProgress.nowPlayTime } else { if (Math.abs(playProgress.nowPlayTime - currentPlayTime) > 2) { window.app_event.activePlayProgressTransition() } } }) onBeforeUnmount(() => { rOnTimeupdate() rVisibilityChange() // window.app_event.off('play', handlePlay) window.app_event.off('pause', handlePause) window.app_event.off('stop', handleStop) window.app_event.off('error', handleError) window.app_event.off('setProgress', setProgress) // window.app_event.off(eventPlayerNames.restorePlay, handleRestorePlay) window.app_event.off('playerLoadeddata', handleLoadeddata) window.app_event.off('playerPlaying', handlePlaying) window.app_event.off('playerWaiting', handleWating) window.app_event.off('playerEmptied', handleEmpied) window.app_event.off('musicToggled', handleSetPlayInfo) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlayStatus.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { sendPlayerStatus, onPlayerAction } from '@renderer/utils/ipc' // import store from '@renderer/store' import { loveList } from '@renderer/store/list/state' import { addListMusics, removeListMusics, checkListExistMusic } from '@renderer/store/list/action' import { playMusicInfo, musicInfo } from '@renderer/store/player/state' import { throttle } from '@common/utils' import { pause, play, playNext, playPrev } from '@renderer/core/player' import { playProgress } from '@renderer/store/player/playProgress' import { appSetting } from '@renderer/store/setting' import { lyric } from '@renderer/store/player/lyric' export default () => { // const setVisibleDesktopLyric = useCommit('setVisibleDesktopLyric') // const setLockDesktopLyric = useCommit('setLockDesktopLyric') let collect = false const updateCollectStatus = async() => { let status = !!playMusicInfo.musicInfo && await checkListExistMusic(loveList.id, playMusicInfo.musicInfo.id) if (collect == status) return false collect = status return true } const handlePlay = () => { sendPlayerStatus({ status: 'playing' }) } const handlePause = () => { sendPlayerStatus({ status: 'paused' }) } const handleStop = () => { if (playMusicInfo.musicInfo != null) return sendPlayerStatus({ status: 'stoped' }) } const handleError = () => { sendPlayerStatus({ status: 'error' }) } const handleSetPlayInfo = async() => { await updateCollectStatus() sendPlayerStatus({ collect, name: musicInfo.name, singer: musicInfo.singer, albumName: musicInfo.album, picUrl: musicInfo.pic ?? '', lyric: musicInfo.lrc ?? '', lyricLineText: '', lyricLineAllText: '', }) } const handleSetLyric = () => { sendPlayerStatus({ lyric: musicInfo.lrc ?? '', tlyric: musicInfo.tlrc ?? '', rlyric: musicInfo.rlrc ?? '', lxlyric: musicInfo.lxlrc ?? '', lyricLineText: '', lyricLineAllText: '', }) } const handleSetPic = () => { sendPlayerStatus({ picUrl: musicInfo.pic ?? '', }) } const handleSetLyricLine = (text: string, line: number) => { let curLine = lyric.lines[line]?.extendedLyrics.join('\n') ?? '' sendPlayerStatus({ lyricLineText: text, lyricLineAllText: curLine ? text + '\n' + curLine : text, }) } // const handleSetTaskbarThumbnailClip = (clip) => { // setTaskbarThumbnailClip(clip) // } const throttleListChange = throttle(async listIds => { if (!listIds.includes(loveList.id)) return if (await updateCollectStatus()) sendPlayerStatus({ collect }) }) // const updateSetting = () => { // const setting = store.getters.setting // buttons.lrc = setting.desktopLyric.enable // buttons.lockLrc = setting.desktopLyric.isLock // setButtons() // } const rTaskbarThumbarClick = onPlayerAction(async({ params: { action, data } }) => { switch (action) { case 'play': play() break case 'pause': pause() break case 'prev': void playPrev() break case 'next': void playNext() break case 'collect': if (!playMusicInfo.musicInfo) return void addListMusics(loveList.id, ['progress' in playMusicInfo.musicInfo ? playMusicInfo.musicInfo.metadata.musicInfo : playMusicInfo.musicInfo]) if (await updateCollectStatus()) sendPlayerStatus({ collect }) break case 'unCollect': if (!playMusicInfo.musicInfo) return void removeListMusics({ listId: loveList.id, ids: ['progress' in playMusicInfo.musicInfo ? playMusicInfo.musicInfo.metadata.musicInfo.id : playMusicInfo.musicInfo.id] }) if (await updateCollectStatus()) sendPlayerStatus({ collect }) break case 'seek': { let progress = data as number if (progress < 0) progress = 0 else if (progress > playProgress.maxPlayTime) progress = playProgress.maxPlayTime window.app_event.setProgress(progress) break } case 'mute': window.app_event.setVolumeIsMute(data as boolean) break case 'volume': window.app_event.setVolume(data as number) break // case 'lrc': // setVisibleDesktopLyric(true) // updateSetting() // break // case 'unLrc': // setVisibleDesktopLyric(false) // updateSetting() // break // case 'lockLrc': // setLockDesktopLyric(true) // updateSetting() // break // case 'unlockLrc': // setLockDesktopLyric(false) // updateSetting() // break } }) watch(() => playProgress.nowPlayTime, (newValue, oldValue) => { // console.log(playProgress.nowPlayTime, newValue, oldValue) // if (newValue.toFixed(2) === oldValue.toFixed(2)) return // console.log(playProgress.nowPlayTime) sendPlayerStatus({ progress: newValue }) }) watch(() => playProgress.maxPlayTime, (newValue) => { sendPlayerStatus({ duration: newValue }) }) watch(() => appSetting['player.playbackRate'], rate => { sendPlayerStatus({ playbackRate: rate }) }) window.app_event.on('play', handlePlay) window.app_event.on('pause', handlePause) window.app_event.on('stop', handleStop) window.app_event.on('error', handleError) window.app_event.on('musicToggled', handleSetPlayInfo) window.app_event.on('lyricUpdated', handleSetLyric) window.app_event.on('picUpdated', handleSetPic) window.app_event.on('lyricLinePlay', handleSetLyricLine) // window.app_event.on(eventTaskbarNames.setTaskbarThumbnailClip, handleSetTaskbarThumbnailClip) window.app_event.on('myListUpdate', throttleListChange) onBeforeUnmount(() => { rTaskbarThumbarClick() window.app_event.off('play', handlePlay) window.app_event.off('pause', handlePause) window.app_event.off('stop', handleStop) window.app_event.off('error', handleError) window.app_event.off('musicToggled', handleSetPlayInfo) window.app_event.off('lyricUpdated', handleSetLyric) window.app_event.off('picUpdated', handleSetPic) window.app_event.off('lyricLinePlay', handleSetLyricLine) // window.app_event.off(eventTaskbarNames.setTaskbarThumbnailClip, handleSetTaskbarThumbnailClip) window.app_event.off('myListUpdate', throttleListChange) }) return async() => { // const setting = store.getters.setting // buttons.lrc = setting.desktopLyric.enable // buttons.lockLrc = setting.desktopLyric.isLock await updateCollectStatus() if (playMusicInfo.musicInfo == null) return sendPlayerStatus({ collect, name: musicInfo.name, singer: musicInfo.singer, albumName: musicInfo.album, playbackRate: appSetting['player.playbackRate'], picUrl: musicInfo.pic ?? '', lyric: musicInfo.lrc ?? '', tlyric: musicInfo.tlrc ?? '', rlyric: musicInfo.rlrc ?? '', lxlyric: musicInfo.lxlrc ?? '', }) } } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlaybackRate.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { setPlaybackRate as setPlayerPlaybackRate, setPreservesPitch } from '@renderer/plugins/player' import { debounce } from '@common/utils' // import { HOTKEY_PLAYER } from '@common/hotKey' import { playbackRate, setPlaybackRate } from '@renderer/store/player/playbackRate' import { appSetting, savePlaybackRate } from '@renderer/store/setting' export default () => { const handleSavePlaybackRate = debounce(savePlaybackRate, 300) setPlaybackRate(appSetting['player.playbackRate']) setPlayerPlaybackRate(appSetting['player.playbackRate']) setPreservesPitch(appSetting['player.preservesPitch']) const handleSetPlaybackRate = (num: number) => { const rate = num < 0.5 ? 0.5 : num > 2 ? 2 : num setPlaybackRate(rate) } // const handleSetPlaybackRateUp = (step = 0.02) => { // handleSetPlaybackRate(volume.value + step) // } // const handleSetPlaybackRateDown = (step = 0.02) => { // handleSetPlaybackRate(volume.value - step) // } // const hotkeyVolumeUp = () => { // handleSetPlaybackRateUp() // } // const hotkeyVolumeDown = () => { // handleSetPlaybackRateDown() // } watch(playbackRate, rate => { handleSavePlaybackRate(rate) setPlayerPlaybackRate(rate) }) watch(() => appSetting['player.playbackRate'], rate => { setPlaybackRate(rate) }) watch(() => appSetting['player.preservesPitch'], preservesPitch => { setPreservesPitch(preservesPitch) }) // window.key_event.on(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) // window.key_event.on(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) window.app_event.on('setPlaybackRate', handleSetPlaybackRate) onBeforeUnmount(() => { // window.key_event.off(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) // window.key_event.off(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) window.app_event.off('setPlaybackRate', handleSetPlaybackRate) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlayer.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { useI18n } from '@renderer/plugins/i18n' import { setTitle } from '@renderer/utils' import { getCurrentTime, getDuration, setPause, setStop, } from '@renderer/plugins/player' import useMediaSessionInfo from './useMediaSessionInfo' import usePlayProgress from './usePlayProgress' import usePlayEvent from './usePlayEvent' import { musicInfo, playMusicInfo, playedList, } from '@renderer/store/player/state' import { setPlay, setAllStatus, addPlayedList, clearPlayedList, // resetPlayerMusicInfo, } from '@renderer/store/player/action' import { appSetting } from '@renderer/store/setting' import useLyric from './useLyric' import useVolume from './useVolume' import useWatchList from './useWatchList' import { HOTKEY_PLAYER } from '@common/hotKey' import { playNext, pause, playPrev, togglePlay, collectMusic, uncollectMusic, dislikeMusic } from '@renderer/core/player' import usePlaybackRate from './usePlaybackRate' import useSoundEffect from './useSoundEffect' import useMaxOutputChannelCount from './useMaxOutputChannelCount' import { setPowerSaveBlocker } from '@renderer/core/player/utils' import usePreloadNextMusic from './usePreloadNextMusic' export default () => { const t = useI18n() usePlayProgress() useMediaSessionInfo() usePlayEvent() useLyric() useVolume() useMaxOutputChannelCount() useSoundEffect() usePlaybackRate() useWatchList() usePreloadNextMusic() const handlePlayNext = () => { void playNext() } const handlePlayPrev = () => { void playPrev() } const addPowerSaveBlocker = () => { setPowerSaveBlocker(true) } const removePowerSaveBlocker = () => { setPowerSaveBlocker(false) } const setPlayStatus = () => { setPlay(true) } const setPauseStatus = () => { setPlay(false) if (window.lx.isPlayedStop) pause() removePowerSaveBlocker() } const handleUpdatePlayInfo = () => { setTitle(musicInfo.id ? `${musicInfo.name} - ${musicInfo.singer}` : null) } const handleCanplay = () => { if (window.lx.isPlayedStop) { setPause() } } const handleEnded = () => { // setTimeout(() => { setAllStatus(t('player__end')) if (window.lx.isPlayedStop) { console.log('played stop') return } // resetPlayerMusicInfo() // window.app_event.stop() void playNext(true) // }) } const setProgress = (time: number) => { window.app_event.setProgress(time) } const handleSeekforward = () => { const seekOffset = 5 const curTime = getCurrentTime() const time = Math.min(getCurrentTime() + seekOffset, getDuration()) if (Math.trunc(curTime) == Math.trunc(time)) return setProgress(time) } const handleSeekbackward = () => { const seekOffset = 5 const curTime = getCurrentTime() const time = Math.max(getCurrentTime() - seekOffset, 0) if (Math.trunc(curTime) == Math.trunc(time)) return setProgress(time) } const setStopStatus = () => { setPlay(false) setTitle(null) setAllStatus('') setStop() removePowerSaveBlocker() } watch(() => appSetting['player.togglePlayMethod'], newValue => { // setLoopPlay(newValue == 'singleLoop') if (playedList.length) clearPlayedList() if (newValue == 'random' && playMusicInfo.musicInfo && !playMusicInfo.isTempPlay) addPlayedList({ ...(playMusicInfo as LX.Player.PlayMusicInfo) }) }) // setLoopPlay(appSetting['player.togglePlayMethod'] == 'singleLoop') window.key_event.on(HOTKEY_PLAYER.next.action, handlePlayNext) window.key_event.on(HOTKEY_PLAYER.prev.action, handlePlayPrev) window.key_event.on(HOTKEY_PLAYER.toggle_play.action, togglePlay) window.key_event.on(HOTKEY_PLAYER.music_love.action, collectMusic) window.key_event.on(HOTKEY_PLAYER.music_unlove.action, uncollectMusic) window.key_event.on(HOTKEY_PLAYER.music_dislike.action, dislikeMusic) window.key_event.on(HOTKEY_PLAYER.seekbackward.action, handleSeekbackward) window.key_event.on(HOTKEY_PLAYER.seekforward.action, handleSeekforward) window.app_event.on('play', setPlayStatus) window.app_event.on('pause', setPauseStatus) window.app_event.on('error', setPauseStatus) window.app_event.on('stop', setStopStatus) window.app_event.on('musicToggled', handleUpdatePlayInfo) window.app_event.on('playerCanplay', handleCanplay) window.app_event.on('playerPlaying', addPowerSaveBlocker) window.app_event.on('playerEmptied', removePowerSaveBlocker) window.app_event.on('playerEnded', handleEnded) onBeforeUnmount(() => { // eslint-disable-next-line @typescript-eslint/no-misused-promises window.key_event.off(HOTKEY_PLAYER.next.action, handlePlayNext) // eslint-disable-next-line @typescript-eslint/no-misused-promises window.key_event.off(HOTKEY_PLAYER.prev.action, handlePlayPrev) window.key_event.off(HOTKEY_PLAYER.toggle_play.action, togglePlay) window.key_event.off(HOTKEY_PLAYER.music_love.action, collectMusic) window.key_event.off(HOTKEY_PLAYER.music_unlove.action, uncollectMusic) window.key_event.off(HOTKEY_PLAYER.music_dislike.action, dislikeMusic) window.key_event.off(HOTKEY_PLAYER.seekbackward.action, handleSeekbackward) window.key_event.off(HOTKEY_PLAYER.seekforward.action, handleSeekforward) window.app_event.off('play', setPlayStatus) window.app_event.off('pause', setPauseStatus) window.app_event.off('error', setPauseStatus) window.app_event.off('stop', setStopStatus) window.app_event.off('musicToggled', handleUpdatePlayInfo) window.app_event.off('playerPlaying', addPowerSaveBlocker) window.app_event.off('playerEmptied', removePowerSaveBlocker) window.app_event.off('playerCanplay', handleCanplay) window.app_event.off('playerEnded', handleEnded) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePlayerEvent.ts ================================================ import { onBeforeUnmount } from '@common/utils/vueTools' import { onPlaying, onPause, onEnded, onError, onLoadeddata, onLoadstart, onCanplay, onEmptied, onWaiting, getErrorCode, } from '@renderer/plugins/player' export default () => { const rOnPlaying = onPlaying(() => { console.log('onPlaying') window.app_event.playerPlaying() window.app_event.play() }) const rOnPause = onPause(() => { console.log('onPause') window.app_event.playerPause() window.app_event.pause() }) const rOnEnded = onEnded(() => { console.log('onEnded') window.app_event.playerEnded() // window.app_event.pause() }) const rOnError = onError(() => { console.log('onError') const errorCode = getErrorCode() window.app_event.error(errorCode) window.app_event.playerError(errorCode) }) const rOnLoadeddata = onLoadeddata(() => { console.log('onLoadeddata') window.app_event.playerLoadeddata() }) const rOnLoadstart = onLoadstart(() => { console.log('onLoadstart') window.app_event.playerLoadstart() }) const rOnCanplay = onCanplay(() => { console.log('onCanplay') window.app_event.playerCanplay() }) const rOnEmptied = onEmptied(() => { console.log('onEmptied') window.app_event.playerEmptied() // window.app_event.stop() }) const rOnWaiting = onWaiting(() => { console.log('onWaiting') window.app_event.pause() window.app_event.playerWaiting() }) onBeforeUnmount(() => { rOnPlaying() rOnPause() rOnEnded() rOnError() rOnLoadeddata() rOnLoadstart() rOnCanplay() rOnEmptied() rOnWaiting() }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/usePreloadNextMusic.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { onTimeupdate, getCurrentTime } from '@renderer/plugins/player' import { playProgress } from '@renderer/store/player/playProgress' import { musicInfo } from '@renderer/store/player/state' // import { getList } from '@renderer/store/utils' import { getNextPlayMusicInfo, resetRandomNextMusicInfo } from '@renderer/core/player' import { getMusicUrl } from '@renderer/core/music' import { appSetting } from '@renderer/store/setting' let audio: HTMLAudioElement const initAudio = () => { if (audio) return audio = new Audio() audio.controls = false audio.preload = 'auto' audio.crossOrigin = 'anonymous' audio.muted = true audio.volume = 0 audio.autoplay = true audio.addEventListener('playing', () => { audio.pause() }) } const checkMusicUrl = async(url: string): Promise => { initAudio() return new Promise((resolve) => { const clear = () => { audio.removeEventListener('error', handleErr) audio.removeEventListener('canplay', handlePlay) } const handleErr = () => { clear() if (audio?.error?.code !== 1) { resolve(false) } else { resolve(true) } } const handlePlay = () => { clear() resolve(true) } audio.addEventListener('error', handleErr) audio.addEventListener('canplay', handlePlay) audio.src = url }) } const preloadMusicInfo = { isLoading: false, preProgress: 0, info: null as LX.Player.PlayMusicInfo | null, } const resetPreloadInfo = () => { preloadMusicInfo.preProgress = 0 preloadMusicInfo.info = null preloadMusicInfo.isLoading = false } const preloadNextMusicUrl = async(curTime: number) => { if (preloadMusicInfo.isLoading || curTime - preloadMusicInfo.preProgress < 3) return preloadMusicInfo.isLoading = true console.log('preload next music url') const info = await getNextPlayMusicInfo() if (info) { preloadMusicInfo.info = info const url = await getMusicUrl({ musicInfo: info.musicInfo }).catch(() => '') if (url) { console.log('preload url', url) const result = await checkMusicUrl(url) if (!result) { const url = await getMusicUrl({ musicInfo: info.musicInfo, isRefresh: true }).catch(() => '') void checkMusicUrl(url) console.log('preload url refresh', url) } } } preloadMusicInfo.isLoading = false } export default () => { const setProgress = (time: number) => { if (!musicInfo.id) return preloadMusicInfo.preProgress = time } const handleSetPlayInfo = () => { resetPreloadInfo() } watch(() => appSetting['player.togglePlayMethod'], () => { if (!preloadMusicInfo.info || preloadMusicInfo.info.isTempPlay) return resetRandomNextMusicInfo() preloadMusicInfo.info = null preloadMusicInfo.preProgress = playProgress.nowPlayTime }) window.app_event.on('setProgress', setProgress) window.app_event.on('musicToggled', handleSetPlayInfo) const rOnTimeupdate = onTimeupdate(() => { const time = getCurrentTime() const duration = playProgress.maxPlayTime if (duration > 10 && duration - time < 10 && !preloadMusicInfo.info) { void preloadNextMusicUrl(time) } }) onBeforeUnmount(() => { rOnTimeupdate() window.app_event.off('setProgress', setProgress) window.app_event.off('musicToggled', handleSetPlayInfo) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useSoundEffect.ts ================================================ import { watch } from '@common/utils/vueTools' import { freqs, getAudioContext, getBiquadFilter, setConvolver, setPannerSoundR, setPannerSpeed, startPanner, stopPanner, setConvolverMainGain, setConvolverSendGain, setPitchShifter, } from '@renderer/plugins/player' import { appSetting } from '@renderer/store/setting' const cache = new Map() const loadBuffer = async(name: string) => new Promise((resolve, reject) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const path = require('@renderer/assets/medias/filters/' + name) as string if (cache.has(path)) { resolve(cache.get(path)!) return } // Load buffer asynchronously let request = new XMLHttpRequest() request.open('GET', path, true) request.responseType = 'arraybuffer' request.onload = function() { // Asynchronously decode the audio file data in request.response void getAudioContext().decodeAudioData(request.response, (buffer) => { if (!buffer) { reject(new Error('error decoding file data: ' + path)) return } cache.set(path, buffer) resolve(buffer) }, function(error) { reject(error) console.error('decodeAudioData error', error) }) } request.onerror = function() { reject(new Error('XHR error')) } request.send() }) export default () => { // console.log(appSetting['player.soundEffect.panner.enable']) if (appSetting['player.soundEffect.panner.enable']) startPanner() setPannerSoundR(appSetting['player.soundEffect.panner.soundR'] / 10) setPannerSpeed(2 * (appSetting['player.soundEffect.panner.speed'] / 10)) if (freqs.some(v => appSetting[`player.soundEffect.biquadFilter.hz${v}`] != 0)) { const bfs = getBiquadFilter() for (const item of freqs) { bfs.get(`hz${item}`)!.gain.value = appSetting[`player.soundEffect.biquadFilter.hz${item}`] } } if (appSetting['player.soundEffect.convolution.fileName']) { void loadBuffer(appSetting['player.soundEffect.convolution.fileName']).then((buffer) => { setConvolver(buffer, appSetting['player.soundEffect.convolution.mainGain'] / 10, appSetting['player.soundEffect.convolution.sendGain'] / 10) }) } if (appSetting['player.soundEffect.pitchShifter.playbackRate'] != 1) { setPitchShifter(appSetting['player.soundEffect.pitchShifter.playbackRate']) } watch(() => appSetting['player.soundEffect.panner.enable'], (enable) => { if (enable) { startPanner() } else { stopPanner() } }) watch(() => appSetting['player.soundEffect.panner.soundR'], (soundR) => { setPannerSoundR(soundR / 10) }) watch(() => appSetting['player.soundEffect.panner.speed'], (speed) => { setPannerSpeed(2 * (speed / 10)) }) watch(() => appSetting['player.soundEffect.convolution.fileName'], (fileName) => { setTimeout(() => { if (fileName) { void loadBuffer(fileName).then((buffer) => { setConvolver(buffer, appSetting['player.soundEffect.convolution.mainGain'] / 10, appSetting['player.soundEffect.convolution.sendGain'] / 10) }) } else { setConvolver(null, 0, 0) } }) }) watch(() => appSetting['player.soundEffect.convolution.mainGain'], (mainGain) => { if (!appSetting['player.soundEffect.convolution.fileName']) return setConvolverMainGain(mainGain / 10) }) watch(() => appSetting['player.soundEffect.convolution.sendGain'], (sendGain) => { if (!appSetting['player.soundEffect.convolution.fileName']) return setConvolverSendGain(sendGain / 10) }) watch(() => appSetting['player.soundEffect.biquadFilter.hz31'], (hz31) => { const bfs = getBiquadFilter() bfs.get('hz31')!.gain.value = hz31 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz62'], (hz62) => { const bfs = getBiquadFilter() bfs.get('hz62')!.gain.value = hz62 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz125'], (hz125) => { const bfs = getBiquadFilter() bfs.get('hz125')!.gain.value = hz125 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz250'], (hz250) => { const bfs = getBiquadFilter() bfs.get('hz250')!.gain.value = hz250 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz500'], (hz500) => { const bfs = getBiquadFilter() bfs.get('hz500')!.gain.value = hz500 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz1000'], (hz1000) => { const bfs = getBiquadFilter() bfs.get('hz1000')!.gain.value = hz1000 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz2000'], (hz2000) => { const bfs = getBiquadFilter() bfs.get('hz2000')!.gain.value = hz2000 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz4000'], (hz4000) => { const bfs = getBiquadFilter() bfs.get('hz4000')!.gain.value = hz4000 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz8000'], (hz8000) => { const bfs = getBiquadFilter() bfs.get('hz8000')!.gain.value = hz8000 }) watch(() => appSetting['player.soundEffect.biquadFilter.hz16000'], (hz16000) => { const bfs = getBiquadFilter() bfs.get('hz16000')!.gain.value = hz16000 }) watch(() => appSetting['player.soundEffect.pitchShifter.playbackRate'], (playbackRate) => { setPitchShifter(playbackRate) }) // window.key_event.on(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) // window.key_event.on(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) // window.app_event.on('setPlaybackRate', handleSetPlaybackRate) // onBeforeUnmount(() => { // // window.key_event.off(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) // // window.key_event.off(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) // window.app_event.off('setPlaybackRate', handleSetPlaybackRate) // }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useVolume.ts ================================================ import { onBeforeUnmount, watch } from '@common/utils/vueTools' import { setVolume as setPlayerVolume, setMute as setPlayerMute } from '@renderer/plugins/player' import { debounce } from '@common/utils' import { HOTKEY_PLAYER } from '@common/hotKey' // import { player as eventPlayerNames } from '@renderer/event/names' import { volume, isMute, setMute, setVolume } from '@renderer/store/player/volume' import { appSetting, saveVolume, saveVolumeIsMute } from '@renderer/store/setting' export default () => { const handleSaveVolume = debounce(saveVolume, 300) setVolume(appSetting['player.volume']) setMute(appSetting['player.isMute']) setPlayerVolume(appSetting['player.volume']) setPlayerMute(appSetting['player.isMute']) const handleToggleVolumeMute = (_isMute?: boolean) => { let muteStatus = _isMute ?? !isMute.value saveVolumeIsMute(muteStatus) setMute(muteStatus) } const handleSetVolume = (num: number) => { const _volume = num < 0 ? 0 : num > 1 ? 1 : num setVolume(_volume) } const handleSetVolumeUp = (step = 0.04) => { handleSetVolume(volume.value + step) } const handleSetVolumeDown = (step = 0.04) => { handleSetVolume(volume.value - step) } const hotkeyVolumeUp = () => { handleSetVolumeUp() } const hotkeyVolumeDown = () => { handleSetVolumeDown() } const hotkeyVolumeMute = () => { handleToggleVolumeMute() } watch(volume, _volume => { handleSaveVolume(_volume) setPlayerVolume(_volume) }) watch(isMute, mute => { saveVolumeIsMute(mute) setPlayerMute(mute) }) watch(() => appSetting['player.volume'], _volume => { setVolume(_volume) }) watch(() => appSetting['player.isMute'], muteStatus => { setMute(muteStatus) }) window.key_event.on(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) window.key_event.on(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) window.key_event.on(HOTKEY_PLAYER.volume_mute.action, hotkeyVolumeMute) window.app_event.on('setVolume', handleSetVolume) window.app_event.on('setVolumeIsMute', handleToggleVolumeMute) onBeforeUnmount(() => { window.key_event.off(HOTKEY_PLAYER.volume_up.action, hotkeyVolumeUp) window.key_event.off(HOTKEY_PLAYER.volume_down.action, hotkeyVolumeDown) window.key_event.off(HOTKEY_PLAYER.volume_mute.action, hotkeyVolumeMute) window.app_event.off('setVolume', handleSetVolume) window.app_event.off('setVolumeIsMute', handleToggleVolumeMute) }) } ================================================ FILE: src/renderer/core/useApp/usePlayer/useWatchList.ts ================================================ import { onBeforeUnmount } from '@common/utils/vueTools' import { playInfo, playMusicInfo } from '@renderer/store/player/state' import { setPlayMusicInfo, updatePlayIndex } from '@renderer/store/player/action' import { throttle } from '@common/utils' import { playNext, stop } from '@renderer/core/player' const changedListIds = new Set() export default () => { const throttleListChange = throttle(() => { const isSkip = playMusicInfo.listId && !changedListIds.has(playInfo.playerListId) && !changedListIds.has(playMusicInfo.listId) changedListIds.clear() if (isSkip) return const { playIndex } = updatePlayIndex() if (playIndex < 0) { // 歌曲被移除 if (window.lx.isPlayedStop) { stop() setTimeout(() => { setPlayMusicInfo(null, null) }) } else if (!playMusicInfo.isTempPlay) { console.log('current music removed') void playNext(true) } } }) const handleListChange = (listIds: string[]) => { for (const id of listIds) { changedListIds.add(id) } throttleListChange() } const handleDownloadListChange = () => { handleListChange(['download']) } window.app_event.on('myListUpdate', handleListChange) window.app_event.on('downloadListUpdate', handleDownloadListChange) onBeforeUnmount(() => { window.app_event.off('myListUpdate', handleListChange) window.app_event.off('downloadListUpdate', handleDownloadListChange) }) } ================================================ FILE: src/renderer/core/useApp/useSettingSync.ts ================================================ import { watch } from '@common/utils/vueTools' import { isFullscreen, proxy, sync, windowSizeList } from '@renderer/store' import { appSetting } from '@renderer/store/setting' import { sendSyncAction, setWindowSize } from '@renderer/utils/ipc' import { setLanguage } from '@root/lang' import { setUserApi } from '../apiSource' // import { applyTheme, getThemes } from '@renderer/store/utils' export default () => { watch(() => appSetting['common.windowSizeId'], (index) => { const info = index == null ? windowSizeList[2] : windowSizeList[index] setWindowSize(info.width, info.height) }) watch(() => appSetting['common.fontSize'], (fontSize) => { if (isFullscreen.value) return document.documentElement.style.fontSize = `${fontSize}px` }) watch(() => appSetting['common.langId'], (id) => { if (!id) return setLanguage(id) window.setLang(id) }) watch(() => appSetting['common.apiSource'], apiSource => { void setUserApi(apiSource) }) watch(() => appSetting['common.font'], (val) => { document.documentElement.style.fontFamily = val }, { immediate: true, }) watch(() => appSetting['sync.mode'], (mode) => { sync.mode = mode }) watch(() => appSetting['sync.enable'], enable => { switch (appSetting['sync.mode']) { case 'server': if (appSetting['sync.server.port']) { void sendSyncAction({ action: 'enable_server', data: { enable: appSetting['sync.enable'], port: appSetting['sync.server.port'], }, }).catch(err => { console.log(err) }) } break case 'client': if (appSetting['sync.client.host']) { void sendSyncAction({ action: 'enable_client', data: { enable: appSetting['sync.enable'], host: appSetting['sync.client.host'], }, }).catch(err => { console.log(err) }) } break default: break } sync.enable = enable }) watch(() => appSetting['sync.server.port'], port => { if (appSetting['sync.mode'] == 'server') { void sendSyncAction({ action: 'enable_server', data: { enable: appSetting['sync.enable'], port: appSetting['sync.server.port'], }, }) } sync.server.port = port }) watch(() => appSetting['sync.client.host'], host => { if (appSetting['sync.mode'] == 'client') { void sendSyncAction({ action: 'enable_client', data: { enable: appSetting['sync.enable'], host: appSetting['sync.client.host'], }, }) } sync.client.host = host }) watch(() => appSetting['network.proxy.enable'], enable => { proxy.enable = enable }) watch(() => appSetting['network.proxy.host'], host => { proxy.host = host }) watch(() => appSetting['network.proxy.port'], port => { proxy.port = port }) } ================================================ FILE: src/renderer/core/useApp/useStatusbarLyric.ts ================================================ import { appSetting } from '@renderer/store/setting' import { setDisableAutoPauseBySource } from '@renderer/core/lyric' export default () => { const handleEnable = (enable: boolean) => { setDisableAutoPauseBySource(enable, 'statusBarLyric') } window.app_event.on('configUpdate', (setting) => { if (setting['player.isShowStatusBarLyric'] != null) { handleEnable(setting['player.isShowStatusBarLyric']) } }) return async() => { if (appSetting['player.isShowStatusBarLyric']) { handleEnable(true) } } } ================================================ FILE: src/renderer/core/useApp/useSync.ts ================================================ import { markRaw, onBeforeUnmount } from '@common/utils/vueTools' import { onSyncAction, sendSyncAction } from '@renderer/utils/ipc' import { sync } from '@renderer/store' import { appSetting } from '@renderer/store/setting' import { SYNC_CODE } from '@common/constants_sync' export default () => { const handleSyncList = (event: LX.Sync.SyncMainWindowActions) => { // console.log(event) switch (event.action) { case 'select_mode': sync.deviceName = event.data.deviceName sync.type = event.data.type sync.isShowSyncMode = true break case 'close_select_mode': sync.isShowSyncMode = false break case 'server_status': sync.server.status.status = event.data.status sync.server.status.message = event.data.message sync.server.status.address = markRaw(event.data.address) sync.server.status.code = event.data.code sync.server.status.devices = markRaw(event.data.devices) break case 'client_status': sync.client.status.status = event.data.status sync.client.status.message = event.data.message sync.client.status.address = markRaw(event.data.address) if (event.data.message == SYNC_CODE.missingAuthCode || event.data.message == SYNC_CODE.authFailed) { if (!sync.isShowAuthCodeModal) sync.isShowAuthCodeModal = true } else if (sync.isShowAuthCodeModal) sync.isShowAuthCodeModal = false break } } const rSyncAction = onSyncAction(({ params }) => { handleSyncList(params) }) onBeforeUnmount(() => { rSyncAction() }) return async() => { sync.enable = appSetting['sync.enable'] sync.mode = appSetting['sync.mode'] sync.server.port = appSetting['sync.server.port'] sync.client.host = appSetting['sync.client.host'] if (appSetting['sync.enable']) { switch (appSetting['sync.mode']) { case 'server': if (appSetting['sync.server.port']) { void sendSyncAction({ action: 'enable_server', data: { enable: appSetting['sync.enable'], port: appSetting['sync.server.port'], }, }).catch(err => { console.log(err) }) } break case 'client': if (appSetting['sync.client.host']) { void sendSyncAction({ action: 'enable_client', data: { enable: appSetting['sync.enable'], host: appSetting['sync.client.host'], }, }).catch(err => { console.log(err) }) } break default: break } } } } ================================================ FILE: src/renderer/core/useApp/useUpdate.ts ================================================ import { nextTick, onBeforeUnmount, watch } from '@common/utils/vueTools' import { onUpdateAvailable, onUpdateDownloaded, onUpdateError, onUpdateNotAvailable, onUpdateProgress, getIgnoreVersion, getLastStartInfo, saveLastStartInfo, } from '@renderer/utils/ipc' import { compareVer, isWin } from '@common/utils' import { isShowChangeLog, versionInfo } from '@renderer/store' import { getVersionInfo } from '@renderer/utils/update' import { dialog } from '@renderer/plugins/Dialog' import { appSetting } from '@renderer/store/setting' export default () => { let isShowedChangeLog = false // 更新超时定时器 let updateTimeout: number | null = null const startUpdateTimeout = () => { if (window.lx.isProd && !(isWin && process.arch.includes('arm'))) { updateTimeout = window.setTimeout(() => { updateTimeout = null void nextTick(() => { showUpdateModal() setTimeout(() => { void dialog({ message: window.i18n.t('update__timeout_top'), confirmButtonText: window.i18n.t('alert_button_text'), }) }, 500) }) }, 60 * 60 * 1000) } } const clearUpdateTimeout = () => { if (!updateTimeout) return clearTimeout(updateTimeout) updateTimeout = null } const handleShowChangeLog = () => { isShowedChangeLog = true void getLastStartInfo().then((version) => { if (version == process.versions.app) return saveLastStartInfo(process.versions.app) if (!appSetting['common.showChangeLog']) return if (version) { if (compareVer(process.versions.app, version) < 0) { void dialog({ message: window.i18n.t('update__downgrade_tip', { ver: `${version} → ${process.versions.app}` }), confirmButtonText: window.i18n.t('update__ignore_confirm_tip_confirm'), }) return } if (compareVer(version, versionInfo.newVersion!.version) >= 0) return } else if ( // 如果当前版本不在已发布的版本中,则不需要显示更新日志 ![{ version: versionInfo.newVersion!.version, desc: '' }, ...(versionInfo.newVersion!.history ?? [])] .some(i => i.version == process.versions.app) ) return isShowChangeLog.value = true }) } const handleGetVersionInfo = async(): Promise> => { return (versionInfo.newVersion?.history && !versionInfo.reCheck ? Promise.resolve(versionInfo.newVersion) : getVersionInfo().then((body: any) => { versionInfo.newVersion = body return body }) ).catch(() => { if (versionInfo.newVersion) return versionInfo.newVersion let result = { version: '0.0.0', desc: '', } versionInfo.newVersion = result return result }) } let versionInfoPromise: null | ReturnType = null const showUpdateModal = (status?: LX.UpdateStatus) => { if (versionInfoPromise) { if ( // @ts-expect-error versionInfoPromise.resolved && versionInfo.reCheck) { versionInfoPromise = handleGetVersionInfo() } } else versionInfoPromise = handleGetVersionInfo() // eslint-disable-next-line @typescript-eslint/promise-function-async void versionInfoPromise.then((result) => { versionInfo.reCheck = false if (result.version == '0.0.0') { versionInfo.isUnknown = true versionInfo.status = 'error' let ignoreFailTipTime = parseInt(localStorage.getItem('update__check_failed_tip') ?? '0') if (Date.now() - ignoreFailTipTime > 7 * 86400000) { versionInfo.showModal = true } return } versionInfo.isUnknown = false if (compareVer(versionInfo.version, result.version) != -1) { versionInfo.status = 'idle' versionInfo.isLatest = true handleShowChangeLog() return } return getIgnoreVersion().then((ignoreVersion) => { versionInfo.isLatest = false let preStatus = versionInfo.status if (status) versionInfo.status = status if (result.version === ignoreVersion) return void nextTick(() => { versionInfo.showModal = true if (status == 'error' && preStatus == 'downloading' && !localStorage.getItem('update__download_failed_tip')) { setTimeout(() => { void dialog({ message: window.i18n.t('update__error_top'), confirmButtonText: window.i18n.t('alert_button_text'), }).finally(() => { localStorage.setItem('update__download_failed_tip', '1') }) }, 500) } }) }) }).finally(() => { // @ts-expect-error versionInfoPromise!.resolved = true }) } const rUpdateAvailable = onUpdateAvailable(({ params: info }) => { // versionInfo.isDownloading = true // console.log(info) versionInfo.newVersion = { version: info.version, desc: info.releaseNotes as string, } versionInfo.isLatest = false if (appSetting['common.tryAutoUpdate']) { versionInfo.status = 'downloading' startUpdateTimeout() } void nextTick(() => { showUpdateModal() }) }) const rUpdateNotAvailable = onUpdateNotAvailable(({ params: info }) => { clearUpdateTimeout() // versionInfo.newVersion = { // version: info.version, // desc: info.releaseNotes as string, // } void handleGetVersionInfo().finally(() => { versionInfo.isLatest = true versionInfo.isUnknown = false versionInfo.status = 'idle' handleShowChangeLog() }) }) const rUpdateError = onUpdateError((params) => { clearUpdateTimeout() // versionInfo.status = 'error' void nextTick(() => { showUpdateModal('error') }) }) const rUpdateProgress = onUpdateProgress(({ params: progress }) => { versionInfo.downloadProgress = progress }) const rUpdateDownloaded = onUpdateDownloaded(({ params: info }) => { clearUpdateTimeout() // versionInfo.status = 'downloaded' void nextTick(() => { showUpdateModal('downloaded') }) }) watch(() => versionInfo.showModal, (visible) => { if (visible || isShowedChangeLog || versionInfo.status == 'downloaded') return setTimeout(() => { handleShowChangeLog() }, 1000) }) onBeforeUnmount(() => { clearUpdateTimeout() rUpdateAvailable() rUpdateNotAvailable() rUpdateError() rUpdateProgress() rUpdateDownloaded() }) } ================================================ FILE: src/renderer/event/Event.ts ================================================ // import mitt from 'mitt' // import type { Emitter } from 'mitt' export default class Event { listeners: Map any>> constructor() { this.listeners = new Map() } on(eventName: string, listener: (...args: any[]) => any) { let targetListeners = this.listeners.get(eventName) if (!targetListeners) this.listeners.set(eventName, targetListeners = []) targetListeners.push(listener) } off(eventName: string, listener: (...args: any[]) => any) { let targetListeners = this.listeners.get(eventName) if (!targetListeners) return const index = targetListeners.indexOf(listener) if (index < 0) return targetListeners.splice(index, 1) } emit(eventName: string, ...args: any[]) { let targetListeners = this.listeners.get(eventName) if (!targetListeners) return for (const listener of targetListeners) { listener(...args) } } offAll(eventName: string) { let targetListeners = this.listeners.get(eventName) if (!targetListeners) return this.listeners.delete(eventName) } } // export class App_EVENT { // listeners: Map void>> // constructor() { // this.listeners = new Map() // } // on(eventName: string, listener: () => void) { // let targetListeners = this.listeners.get(eventName) // if (targetListeners) this.listeners.set(eventName, targetListeners = []) // targetListeners!.push(listener) // } // off(eventName: string, listener: () => void) { // } // } ================================================ FILE: src/renderer/event/appEvent.ts ================================================ import Event from './Event' // { // // sync: { // // send_action_list: 'send_action_list', // // handle_action_list: 'handle_action_list', // // send_sync_list: 'send_sync_list', // // handle_sync_list: 'handle_sync_list', // // }, // } export class AppEvent extends Event { configUpdate(setting: Partial) { this.emit('configUpdate', setting) } focus() { this.emit('focus') } dragStart() { this.emit('dragStart') } dragEnd() { this.emit('dragEnd') } /** * 音乐信息切换 */ musicToggled() { this.emit('musicToggled') } /** * 手动改变进度 * @param progress 进度 */ setProgress(progress: number, maxPlayTime?: number) { this.emit('setProgress', progress, maxPlayTime) } /** * 设置音量大小 * @param volume 音量大小 */ setVolume(volume: number) { this.emit('setVolume', volume) } /** * 设置播放速率大小 * @param rate 播放速率 */ setPlaybackRate(rate: number) { this.emit('setPlaybackRate', rate) } /** * 设置是否静音 * @param isMute 是否静音 */ setVolumeIsMute(isMute: boolean) { this.emit('setVolumeIsMute', isMute) } // 播放器事件 play() { this.emit('play') } pause() { this.emit('pause') } stop() { this.emit('stop') } error(code?: number) { this.emit('error', code) } // 播放器原始事件 playerPlaying() { this.emit('playerPlaying') } playerPause() { this.emit('playerPause') } playerStop() { this.emit('playerStop') } playerEnded() { this.emit('playerEnded') } playerError(code?: number) { this.emit('playerError', code) } playerLoadeddata() { this.emit('playerLoadeddata') } playerLoadstart() { this.emit('playerLoadstart') } playerCanplay() { this.emit('playerCanplay') } playerEmptied() { this.emit('playerEmptied') } playerWaiting() { this.emit('playerWaiting') } playerDeviceChanged() { this.emit('playerDeviceChanged') } // 激活进度条动画事件 activePlayProgressTransition() { this.emit('activePlayProgressTransition') } // 更新图片事件 picUpdated() { this.emit('picUpdated') } // 更新歌词事件 lyricUpdated() { this.emit('lyricUpdated') } // 更新歌词偏移 lyricOffsetUpdate() { this.emit('lyricOffsetUpdate') } // 歌词行播放 lyricLinePlay(text: string, line: number) { this.emit('lyricLinePlay', text, line) } // 我的列表改变事件 myListUpdate(ids: string[]) { this.emit('myListUpdate', ids) } // 下载列表改变事件 downloadListUpdate() { this.emit('downloadListUpdate') } // 列表里的音乐信息改变事件 // musicInfoUpdate(musicInfo: LX.Music.MusicInfo) { // this.emit('musicInfoUpdate', musicInfo) // } keyDown(event: LX.KeyDownEevent) { this.emit('keyDown', event) } } type EventMethods = Omit declare class EventType extends AppEvent { on(event: K, listener: EventMethods[K]): any off(event: K, listener: EventMethods[K]): any } export type AppEventTypes = Omit> export const createAppEventHub = (): AppEventTypes => { return new AppEvent() } ================================================ FILE: src/renderer/event/index.ts ================================================ import { getHotKeyConfig, onFocus, onKeyDown, onUpdateHotkey } from '@renderer/utils/ipc' import { registerKeyEvent, createKeyEventHub } from './keyEvent' // import { registerRendererEvents, unregisterRendererEvents } from './rendererEvent' import { createAppEventHub } from './appEvent' export const registerEvents = () => { window.lx.isEditingHotKey = false window.app_event = createAppEventHub() window.key_event = createKeyEventHub() const setHotkeyConfig = ({ local, global }: LX.HotKeyConfigAll) => { window.lx.appHotKeyConfig = { local, global, } } void getHotKeyConfig().then(setHotkeyConfig) onUpdateHotkey(({ params }) => { setHotkeyConfig(params) }) onKeyDown(({ params: { key } }) => { const keyInfo = window.lx.appHotKeyConfig.global.keys[key] if (keyInfo) window.key_event.emit(keyInfo.action) }) onFocus(() => { window.app_event.focus() }) registerKeyEvent() // registerRendererEvents() } // export const unregisterEvents = () => { // unregisterKeyEvent() // // unregisterRendererEvents() // } export { clearDownKeys } from './keyEvent' export type { AppEventTypes } from './appEvent' export type { KeyEventTypes } from './keyEvent' registerEvents() ================================================ FILE: src/renderer/event/keyEvent.ts ================================================ import keyBind from '../utils/keyBind' import { HOTKEY_COMMON } from '@common/hotKey' import Event from './Event' import { appSetting } from '@renderer/store/setting' declare class keyEventTypes extends Event { on(event: string, listener: (event: LX.KeyDownEevent) => any): void off(event: string, listener: (event: LX.KeyDownEevent) => any): void } export type KeyEventTypes = keyEventTypes export const createKeyEventHub = (): keyEventTypes => { return new Event() } window.lx.isEditingHotKey = false // let appHotKeyConfig: LX.HotKeyConfigAll = window.lx.appHotKeyConfig export const registerKeyEvent = () => { keyBind.bindKey((key, eventKey, type, event, keys, isEditing) => { // console.log(`key_${key}_${type}`) window.app_event.keyDown({ event, keys, key, eventKey, type }) // console.log(event, key) // console.log(key, eventKey, type, event, keys) if (window.lx.isEditingHotKey || (isEditing && type == 'down') || event?.lx_handled) return if (event && window.lx.appHotKeyConfig.local.enable && window.lx.appHotKeyConfig.local.keys[key] && (key != 'escape' || !((event.target as HTMLElement).classList.contains('ignore-esc')))) { // console.log(key, eventKey, type, keys, isEditing) event.preventDefault() if (type == 'up') return // 软件内快捷键的最小化触发时 // 如果已启用托盘,则隐藏程序,否则最小化程序 https://github.com/lyswhut/lx-music-desktop/issues/603 if (window.lx.appHotKeyConfig.local.keys[key].action == HOTKEY_COMMON.min.action && appSetting['tray.enable']) { window.key_event.emit(HOTKEY_COMMON.hide_toggle.action) return } window.key_event.emit(window.lx.appHotKeyConfig.local.keys[key].action) return } // console.log(`key_${key}_${type}`) window.key_event.emit(`key_${key}_${type}`, { event, keys, key, eventKey, type }) if (key != eventKey) window.key_event.emit(`key_${eventKey}_${type}`, { event, keys, key, eventKey, type }) }) } export const unregisterKeyEvent = () => { keyBind.unbindKey() } export const clearDownKeys = () => { keyBind.clearDownKeys() } ================================================ FILE: src/renderer/index.html ================================================ LX Music ================================================ FILE: src/renderer/main.ts ================================================ import '@common/error' import { createApp } from 'vue' import './core/globalData' import '@renderer/event' // Components import mountComponents from './components' // Plugins import initPlugins from './plugins' import { i18nPlugin } from './plugins/i18n' import App from './App.vue' import router from './router' // import store from './store' import { getSetting, updateSetting } from './utils/ipc' import { langList } from '@root/lang' import type { I18n } from '@root/lang/i18n' import { initSetting } from './store/setting' // import { bubbleCursor } from './utils/cursor-effects/bubbleCursor' import './worker' import { saveViewPrevState } from './utils/data' // sync(store, router) router.afterEach((to) => { if (to.path != '/songList/detail') { saveViewPrevState({ url: to.path, query: { ...to.query }, }) } }) void getSetting().then(setting => { // window.lx.appSetting = setting // Set language automatically if (!setting['common.langId'] || !window.i18n.availableLocales.includes(setting['common.langId'])) { let langId: I18n['locale'] | null = null const locale = window.navigator.language.toLocaleLowerCase() as I18n['locale'] if (window.i18n.availableLocales.includes(locale)) { langId = locale } else { for (const lang of langList) { if (lang.alternate == locale) { langId = lang.locale break } } langId ??= 'en-us' } setting['common.langId'] = langId void updateSetting({ 'common.langId': langId }) console.log('Set lang', setting['common.langId']) } window.setLang(setting['common.langId']) window.i18n.setLanguage(setting['common.langId']) if (!setting['common.startInFullscreen'] && (document.body.clientHeight > window.screen.availHeight || document.body.clientWidth > window.screen.availWidth) && setting['common.windowSizeId'] > 1) { void updateSetting({ 'common.windowSizeId': 1 }) } // store.commit('setSetting', setting) initSetting(setting) const app = createApp(App) app .use(router) // .use(store) .use(i18nPlugin) initPlugins(app) mountComponents(app) app.mount('#root') }) // bubbleCursor() ================================================ FILE: src/renderer/plugins/Dialog/Dialog.vue ================================================ ================================================ FILE: src/renderer/plugins/Dialog/index.js ================================================ import Dialog from './Dialog.vue' import { createApp } from 'vue' const defaultOptions = { message: '', teleport: '#root', showCancel: false, cancelButtonText: '', confirmButtonText: '', selection: false, } export const dialog = function(options) { const { message, showCancel, cancelButtonText, confirmButtonText, teleport, selection } = Object.assign({}, defaultOptions, typeof options == 'string' ? { message: options } : options || {}) return new Promise((resolve, reject) => { let app = createApp(Dialog, { afterLeave() { app?.unmount() app = null }, }) let instance = app.mount(document.createElement('div')) // 属性设置 instance.visible = true instance.message = message instance.showCancel = showCancel instance.cancelButtonText = cancelButtonText instance.confirmButtonText = confirmButtonText instance.teleport = teleport instance.selection = selection // 挂载 document.getElementById('container').appendChild(instance.$el) instance.handleCancel = () => { instance.visible = false resolve(false) } instance.handleComfirm = () => { instance.visible = false resolve(true) } }) } dialog.confirm = options => dialog( typeof options == 'string' ? { message: options, showCancel: true } : { ...options, showCancel: true }, ) const dialogPlugin = { install(Vue, options) { Vue.config.globalProperties.$dialog = dialog }, } export default dialogPlugin ================================================ FILE: src/renderer/plugins/SvgIcon/SvgIcon.vue ================================================ ================================================ FILE: src/renderer/plugins/SvgIcon/index.js ================================================ import SvgIcon from './SvgIcon.vue' const req = require.context('@renderer/assets/svgs', false, /\.svg$/) const requireAll = requireContext => requireContext.keys().map(requireContext) requireAll(req) export default app => { app.component('svg-icon', SvgIcon) } ================================================ FILE: src/renderer/plugins/Tips/Tips.js ================================================ import Tips from './Tips.vue' import { createApp } from 'vue' const addAutoCloseTimer = (instance, time) => { if (!time) return if (instance.autoCloseTimer) clearTimeout(instance.autoCloseTimer) instance.autoCloseTimer = setTimeout(() => { instance.cancel() }, time) } const clearAutoCloseTimer = instance => { if (!instance.autoCloseTimer) return clearTimeout(instance.autoCloseTimer) instance.autoCloseTimer = null } export default ({ position, message, autoCloseTime } = {}, props) => { if (!position) return let app = createApp(Tips, { afterLeave() { app.unmount() app = null }, }) let instance = app.mount(document.createElement('div')) // Tips实例挂载到刚创建的div // 属性设置 instance.visible = true instance.message = message instance.position.top = position.top instance.position.left = position.left // 将Tips的DOM挂载到body上 document.body.appendChild(instance.$el) instance.cancel = () => { props.beforeClose(instance) clearAutoCloseTimer(instance) instance.visible = false instance = null } instance.setTips = tips => { addAutoCloseTimer(instance, autoCloseTime) instance.message = tips } addAutoCloseTimer(instance, autoCloseTime) return instance } ================================================ FILE: src/renderer/plugins/Tips/Tips.vue ================================================ ================================================ FILE: src/renderer/plugins/Tips/index.js ================================================ import tips from './Tips' import { debounce } from '@common/utils' let instance let prevTips let prevX = 0 let prevY = 0 let isDraging = false const getTipText = el => { return el.getAttribute('aria-label') && el.getAttribute('ignore-tip') == null ? el.getAttribute('aria-label') : null } const getTips = el => el ? getTipText(el) ? getTipText(el) : el.parentNode === document.documentElement ? null : getTips(el.parentNode) : null const showTips = debounce(event => { if (isDraging) return let msg = getTips(event.target)?.trim() if (!msg) return prevTips = msg instance = tips({ message: msg, autoCloseTime: 10000, position: { top: event.y + 12, left: event.x + 8, }, }, { beforeClose(closeInstance) { if (instance !== closeInstance) return prevTips = null instance = null }, }) }, 400) const hideTips = () => { if (!instance) return instance.cancel() } const setTips = tips => { if (!instance) return instance.setTips(tips) } const updateTips = event => { if (isDraging) return if (!instance) return showTips(event) setTimeout(() => { let msg = getTips(event.target) if (!msg || prevTips === msg) return setTips(msg) prevTips = msg }) } setTimeout(() => { document.body.addEventListener('mousemove', event => { if ((event.x == prevX && event.y == prevY) || isDraging) return prevX = event.x prevY = event.y hideTips() showTips(event) }) document.body.addEventListener('click', updateTips) document.body.addEventListener('contextmenu', updateTips) window.app_event.on('focus', () => { hideTips() }) window.app_event.on('dragStart', () => { isDraging = true hideTips() }) window.app_event.on('dragEnd', () => { isDraging = false }) }) ================================================ FILE: src/renderer/plugins/i18n.ts ================================================ import type { I18n } from '@root/lang' import { createI18n, i18nPlugin, useI18n } from '@root/lang' window.i18n = createI18n() export { i18nPlugin, useI18n, } export type { I18n } ================================================ FILE: src/renderer/plugins/index.ts ================================================ // import './axios' import { type App } from 'vue' import dialog from './Dialog' import './Tips' import svgIcon from './SvgIcon' export default (app: App) => { app.use(dialog) svgIcon(app) } ================================================ FILE: src/renderer/plugins/player/index.ts ================================================ interface HTMLAudioElementChrome extends HTMLAudioElement { setSinkId: (id: string) => Promise } let audio: HTMLAudioElementChrome | null = null let audioContext: AudioContext let mediaSource: MediaElementAudioSourceNode let analyser: AnalyserNode // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext // https://benzleung.gitbooks.io/web-audio-api-mini-guide/content/chapter5-1.html export const freqs = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] as const type Freqs = (typeof freqs)[number] let biquads: Map<`hz${Freqs}`, BiquadFilterNode> export const freqsPreset = [ { name: 'pop', hz31: 6, hz62: 5, hz125: -3, hz250: -2, hz500: 5, hz1000: 4, hz2000: -4, hz4000: -3, hz8000: 6, hz16000: 4 }, { name: 'dance', hz31: 4, hz62: 3, hz125: -4, hz250: -6, hz500: 0, hz1000: 0, hz2000: 3, hz4000: 4, hz8000: 4, hz16000: 5 }, { name: 'rock', hz31: 7, hz62: 6, hz125: 2, hz250: 1, hz500: -3, hz1000: -4, hz2000: 2, hz4000: 1, hz8000: 4, hz16000: 5 }, { name: 'classical', hz31: 6, hz62: 7, hz125: 1, hz250: 2, hz500: -1, hz1000: 1, hz2000: -4, hz4000: -6, hz8000: -7, hz16000: -8 }, { name: 'vocal', hz31: -5, hz62: -6, hz125: -4, hz250: -3, hz500: 3, hz1000: 4, hz2000: 5, hz4000: 4, hz8000: -3, hz16000: -3 }, { name: 'slow', hz31: 5, hz62: 4, hz125: 2, hz250: 0, hz500: -2, hz1000: 0, hz2000: 3, hz4000: 6, hz8000: 7, hz16000: 8 }, { name: 'electronic', hz31: 6, hz62: 5, hz125: 0, hz250: -5, hz500: -4, hz1000: 0, hz2000: 6, hz4000: 8, hz8000: 8, hz16000: 7 }, { name: 'subwoofer', hz31: 8, hz62: 7, hz125: 5, hz250: 4, hz500: 0, hz1000: 0, hz2000: 0, hz4000: 0, hz8000: 0, hz16000: 0 }, { name: 'soft', hz31: -5, hz62: -5, hz125: -4, hz250: -4, hz500: 3, hz1000: 2, hz2000: 4, hz4000: 4, hz8000: 0, hz16000: 0 }, ] as const export const convolutions = [ { name: 'telephone', mainGain: 0.0, sendGain: 3.0, source: 'filter-telephone.wav' }, // 电话 { name: 's2_r4_bd', mainGain: 1.8, sendGain: 0.9, source: 's2_r4_bd.wav' }, // 教堂 { name: 'bright_hall', mainGain: 0.8, sendGain: 2.4, source: 'bright-hall.wav' }, { name: 'cinema_diningroom', mainGain: 0.6, sendGain: 2.3, source: 'cinema-diningroom.wav' }, { name: 'dining_living_true_stereo', mainGain: 0.6, sendGain: 1.8, source: 'dining-living-true-stereo.wav' }, { name: 'living_bedroom_leveled', mainGain: 0.6, sendGain: 2.1, source: 'living-bedroom-leveled.wav' }, { name: 'spreader50_65ms', mainGain: 1, sendGain: 2.5, source: 'spreader50-65ms.wav' }, // { name: 'spreader25_125ms', mainGain: 1, sendGain: 2.5, source: 'spreader25-125ms.wav' }, // { name: 'backslap', mainGain: 1.8, sendGain: 0.8, source: 'backslap1.wav' }, { name: 's3_r1_bd', mainGain: 1.8, sendGain: 0.8, source: 's3_r1_bd.wav' }, { name: 'matrix_1', mainGain: 1.5, sendGain: 0.9, source: 'matrix-reverb1.wav' }, { name: 'matrix_2', mainGain: 1.3, sendGain: 1, source: 'matrix-reverb2.wav' }, { name: 'cardiod_35_10_spread', mainGain: 1.8, sendGain: 0.6, source: 'cardiod-35-10-spread.wav' }, { name: 'tim_omni_35_10_magnetic', mainGain: 1, sendGain: 0.2, source: 'tim-omni-35-10-magnetic.wav' }, // { name: 'spatialized', mainGain: 1.8, sendGain: 0.8, source: 'spatialized8.wav' }, // { name: 'zing_long_stereo', mainGain: 0.8, sendGain: 1.8, source: 'zing-long-stereo.wav' }, { name: 'feedback_spring', mainGain: 1.8, sendGain: 0.8, source: 'feedback-spring.wav' }, // { name: 'tim_omni_rear_blend', mainGain: 1.8, sendGain: 0.8, source: 'tim-omni-rear-blend.wav' }, ] as const // 半音 // export const semitones = [-1.5, -1, -0.5, 0.5, 1, 1.5, 2, 2.5, 3, 3.5] as const let convolver: ConvolverNode let convolverSourceGainNode: GainNode let convolverOutputGainNode: GainNode let convolverDynamicsCompressor: DynamicsCompressorNode let gainNode: GainNode let panner: PannerNode let pitchShifterNode: AudioWorkletNode let pitchShifterNodePitchFactor: AudioParam | null let pitchShifterNodeLoadStatus: 'none' | 'loading' | 'unconnect' | 'connected' = 'none' let pitchShifterNodeTempValue = 1 let defaultChannelCount = 2 export const soundR = 0.5 export const createAudio = () => { if (audio) return audio = new window.Audio() as HTMLAudioElementChrome audio.controls = false audio.autoplay = true audio.preload = 'auto' audio.crossOrigin = 'anonymous' } const initAnalyser = () => { analyser = audioContext.createAnalyser() analyser.fftSize = 256 } const initBiquadFilter = () => { biquads = new Map() let i for (const item of freqs) { const filter = audioContext.createBiquadFilter() biquads.set(`hz${item}`, filter) filter.type = 'peaking' filter.frequency.value = item filter.Q.value = 1.4 filter.gain.value = 0 } for (i = 1; i < freqs.length; i++) { (biquads.get(`hz${freqs[i - 1]}`)!).connect(biquads.get(`hz${freqs[i]}`)!) } } const initConvolver = () => { convolverSourceGainNode = audioContext.createGain() convolverOutputGainNode = audioContext.createGain() convolverDynamicsCompressor = audioContext.createDynamicsCompressor() convolver = audioContext.createConvolver() convolver.connect(convolverOutputGainNode) convolverSourceGainNode.connect(convolverDynamicsCompressor) convolverOutputGainNode.connect(convolverDynamicsCompressor) } const initPanner = () => { panner = audioContext.createPanner() } const initGain = () => { gainNode = audioContext.createGain() } const initAdvancedAudioFeatures = () => { if (audioContext) return if (!audio) throw new Error('audio not defined') audioContext = new window.AudioContext({ latencyHint: 'playback' }) defaultChannelCount = audioContext.destination.channelCount initAnalyser() initBiquadFilter() initConvolver() initPanner() initGain() // source -> analyser -> biquadFilter -> pitchShifter -> [(convolver & convolverSource)->convolverDynamicsCompressor] -> panner -> gain mediaSource = audioContext.createMediaElementSource(audio) mediaSource.connect(analyser) analyser.connect(biquads.get(`hz${freqs[0]}`)!) const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1)!}`)!) lastBiquadFilter.connect(convolverSourceGainNode) lastBiquadFilter.connect(convolver) convolverDynamicsCompressor.connect(panner) panner.connect(gainNode) gainNode.connect(audioContext.destination) // 音频输出设备改变时刷新 audio node 连接 window.app_event.on('playerDeviceChanged', handleMediaListChange) // audio.addEventListener('playing', connectAudioNode) // audio.addEventListener('pause', disconnectAudioNode) // audio.addEventListener('waiting', disconnectAudioNode) // audio.addEventListener('emptied', disconnectAudioNode) // if (!audio.paused) connectAudioNode() } const handleMediaListChange = () => { mediaSource.disconnect() mediaSource.connect(analyser) } // let isConnected = true // const connectAudioNode = () => { // if (isConnected) return // console.log('connect Node') // mediaSource.connect(analyser) // isConnected = true // if (pitchShifterNodeTempValue == 1 && pitchShifterNodeLoadStatus == 'connected') { // disconnectPitchShifterNode() // } // } // const disconnectAudioNode = () => { // if (!isConnected) return // console.log('disconnect Node') // mediaSource.disconnect() // isConnected = false // if (pitchShifterNodeTempValue == 1 && pitchShifterNodeLoadStatus == 'connected') { // disconnectPitchShifterNode() // } // } export const getAudioContext = () => { initAdvancedAudioFeatures() return audioContext } let unsubMediaListChangeEvent: (() => void) | null = null export const setMaxOutputChannelCount = (enable: boolean) => { if (enable) { initAdvancedAudioFeatures() audioContext.destination.channelCountMode = 'max' audioContext.destination.channelCount = audioContext.destination.maxChannelCount // navigator.mediaDevices.addEventListener('devicechange', handleMediaListChange) if (!unsubMediaListChangeEvent) { let handleMediaListChange = () => { setMaxOutputChannelCount(true) } window.app_event.on('playerDeviceChanged', handleMediaListChange) unsubMediaListChangeEvent = () => { window.app_event.off('playerDeviceChanged', handleMediaListChange) unsubMediaListChangeEvent = null } } } else { unsubMediaListChangeEvent?.() if (audioContext && audioContext.destination.channelCountMode != 'explicit') { audioContext.destination.channelCount = defaultChannelCount // audioContext.destination.channelInterpretation audioContext.destination.channelCountMode = 'explicit' } } } export const getAnalyser = (): AnalyserNode | null => { initAdvancedAudioFeatures() return analyser } export const getBiquadFilter = () => { initAdvancedAudioFeatures() return biquads } // let isConvolverConnected = false export const setConvolver = (buffer: AudioBuffer | null, mainGain: number, sendGain: number) => { initAdvancedAudioFeatures() convolver.buffer = buffer // console.log(mainGain, sendGain) if (buffer) { convolverSourceGainNode.gain.value = mainGain convolverOutputGainNode.gain.value = sendGain } else { convolverSourceGainNode.gain.value = 1 convolverOutputGainNode.gain.value = 0 } } export const setConvolverMainGain = (gain: number) => { if (convolverSourceGainNode.gain.value == gain) return // console.log(gain) convolverSourceGainNode.gain.value = gain } export const setConvolverSendGain = (gain: number) => { if (convolverOutputGainNode.gain.value == gain) return // console.log(gain) convolverOutputGainNode.gain.value = gain } let pannerInfo = { x: 0, y: 0, z: 0, soundR: 0.5, rad: 0, speed: 1, intv: null as NodeJS.Timeout | null, } const setPannerXYZ = (nx: number, ny: number, nz: number) => { pannerInfo.x = nx pannerInfo.y = ny pannerInfo.z = nz // console.log(pannerInfo) panner.positionX.value = nx * pannerInfo.soundR panner.positionY.value = ny * pannerInfo.soundR panner.positionZ.value = nz * pannerInfo.soundR } export const setPannerSoundR = (r: number) => { pannerInfo.soundR = r } export const setPannerSpeed = (speed: number) => { pannerInfo.speed = speed if (pannerInfo.intv) startPanner() } export const stopPanner = () => { if (pannerInfo.intv) { clearInterval(pannerInfo.intv) pannerInfo.intv = null pannerInfo.rad = 0 } panner.positionX.value = 0 panner.positionY.value = 0 panner.positionZ.value = 0 } export const startPanner = () => { initAdvancedAudioFeatures() if (pannerInfo.intv) { clearInterval(pannerInfo.intv) pannerInfo.intv = null pannerInfo.rad = 0 } pannerInfo.intv = setInterval(() => { pannerInfo.rad += 1 if (pannerInfo.rad > 360) pannerInfo.rad -= 360 setPannerXYZ(Math.sin(pannerInfo.rad * Math.PI / 180), Math.cos(pannerInfo.rad * Math.PI / 180), Math.cos(pannerInfo.rad * Math.PI / 180)) }, pannerInfo.speed * 10) } let isConnected = true const connectNode = () => { if (isConnected) return console.log('connect Node') analyser?.connect(biquads.get(`hz${freqs[0]}`)!) isConnected = true if (pitchShifterNodeTempValue == 1 && pitchShifterNodeLoadStatus == 'connected') { disconnectPitchShifterNode() } } const disconnectNode = () => { if (!isConnected) return console.log('disconnect Node') analyser?.disconnect() isConnected = false if (pitchShifterNodeTempValue == 1 && pitchShifterNodeLoadStatus == 'connected') { disconnectPitchShifterNode() } } const connectPitchShifterNode = () => { console.log('connect Pitch Shifter Node') audio!.addEventListener('playing', connectNode) audio!.addEventListener('pause', disconnectNode) audio!.addEventListener('waiting', disconnectNode) audio!.addEventListener('emptied', disconnectNode) if (audio!.paused) disconnectNode() const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1)!}`)!) lastBiquadFilter.disconnect() lastBiquadFilter.connect(pitchShifterNode) pitchShifterNode.connect(convolver) pitchShifterNode.connect(convolverSourceGainNode) // convolverDynamicsCompressor.disconnect(panner) // convolverDynamicsCompressor.connect(pitchShifterNode) // pitchShifterNode.connect(panner) pitchShifterNodeLoadStatus = 'connected' pitchShifterNodePitchFactor!.value = pitchShifterNodeTempValue } const disconnectPitchShifterNode = () => { console.log('disconnect Pitch Shifter Node') const lastBiquadFilter = (biquads.get(`hz${freqs.at(-1)!}`)!) lastBiquadFilter.disconnect() lastBiquadFilter.connect(convolver) lastBiquadFilter.connect(convolverSourceGainNode) pitchShifterNodeLoadStatus = 'unconnect' pitchShifterNodePitchFactor = null audio!.removeEventListener('playing', connectNode) audio!.removeEventListener('pause', disconnectNode) audio!.removeEventListener('waiting', disconnectNode) audio!.removeEventListener('emptied', disconnectNode) connectNode() } const loadPitchShifterNode = () => { pitchShifterNodeLoadStatus = 'loading' initAdvancedAudioFeatures() // source -> analyser -> biquadFilter -> audioWorklet(pitch shifter) -> [(convolver & convolverSource)->convolverDynamicsCompressor] -> panner -> gain void audioContext.audioWorklet.addModule(new URL( /* webpackChunkName: 'pitch_shifter.audioWorklet' */ './pitch-shifter/phase-vocoder.js', import.meta.url, )).then(() => { console.log('pitch shifter audio worklet loaded') // https://github.com/olvb/phaze/issues/26#issuecomment-1574629971 pitchShifterNode = new AudioWorkletNode(audioContext, 'phase-vocoder-processor', { outputChannelCount: [2] }) let pitchFactorParam = pitchShifterNode.parameters.get('pitchFactor') if (!pitchFactorParam) return pitchShifterNodePitchFactor = pitchFactorParam pitchShifterNodeLoadStatus = 'unconnect' if (pitchShifterNodeTempValue == 1) return connectPitchShifterNode() }) } export const setPitchShifter = (val: number) => { // console.log('setPitchShifter', val) pitchShifterNodeTempValue = val switch (pitchShifterNodeLoadStatus) { case 'loading': break case 'none': loadPitchShifterNode() break case 'connected': // a: 1 = 半音 // value = 2 ** (a / 12) pitchShifterNodePitchFactor!.value = val break case 'unconnect': connectPitchShifterNode() break } } export const hasInitedAdvancedAudioFeatures = (): boolean => audioContext != null export const setResource = (src: string) => { if (audio) audio.src = src } export const setPlay = () => { void audio?.play() } export const setPause = () => { audio?.pause() } export const setStop = () => { if (audio) { audio.src = '' audio.removeAttribute('src') } } export const isEmpty = (): boolean => !audio?.src export const setLoopPlay = (isLoop: boolean) => { if (audio) audio.loop = isLoop } export const getPlaybackRate = (): number => { return audio?.defaultPlaybackRate ?? 1 } export const setPlaybackRate = (rate: number) => { if (!audio) return audio.defaultPlaybackRate = rate audio.playbackRate = rate } export const setPreservesPitch = (preservesPitch: boolean) => { if (!audio) return audio.preservesPitch = preservesPitch } export const getMute = (): boolean => { return audio?.muted ?? false } export const setMute = (isMute: boolean) => { if (audio) audio.muted = isMute } export const getCurrentTime = () => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return audio?.currentTime || 0 } export const setCurrentTime = (time: number) => { if (audio) audio.currentTime = time } export const setMediaDeviceId = async(mediaDeviceId: string): Promise => { if (!audio) return return audio.setSinkId(mediaDeviceId) } export const setVolume = (volume: number) => { if (audio) audio.volume = volume } export const getDuration = () => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return audio?.duration || 0 } // export const getPlaybackRate = () => { // return audio?.playbackRate ?? 1 // } type Noop = () => void export const onPlaying = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('playing', callback) return () => { audio?.removeEventListener('playing', callback) } } export const onPause = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio?.addEventListener('pause', callback) return () => { audio?.removeEventListener('pause', callback) } } export const onEnded = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('ended', callback) return () => { audio?.removeEventListener('ended', callback) } } export const onError = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('error', callback) return () => { audio?.removeEventListener('error', callback) } } export const onLoadeddata = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('loadeddata', callback) return () => { audio?.removeEventListener('loadeddata', callback) } } export const onLoadstart = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('loadstart', callback) return () => { audio?.removeEventListener('loadstart', callback) } } export const onCanplay = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('canplay', callback) return () => { audio?.removeEventListener('canplay', callback) } } export const onEmptied = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('emptied', callback) return () => { audio?.removeEventListener('emptied', callback) } } export const onTimeupdate = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('timeupdate', callback) return () => { audio?.removeEventListener('timeupdate', callback) } } // 缓冲中 export const onWaiting = (callback: Noop) => { if (!audio) throw new Error('audio not defined') audio.addEventListener('waiting', callback) return () => { audio?.removeEventListener('waiting', callback) } } // 可见性改变 export const onVisibilityChange = (callback: Noop) => { document.addEventListener('visibilitychange', callback) return () => { document.removeEventListener('visibilitychange', callback) } } export const getErrorCode = () => { return audio?.error?.code } ================================================ FILE: src/renderer/plugins/player/pitch-shifter/fft.js ================================================ // https://github.com/indutny/fft.js function FFT(size) { this.size = size | 0 if (this.size <= 1 || (this.size & (this.size - 1)) !== 0) { throw new Error('FFT size must be a power of two and bigger than 1') } this._csize = size << 1 // NOTE: Use of `var` is intentional for old V8 versions let table = new Array(this.size * 2) for (let i = 0; i < table.length; i += 2) { const angle = Math.PI * i / this.size table[i] = Math.cos(angle) table[i + 1] = -Math.sin(angle) } this.table = table // Find size's power of two let power = 0 for (let t = 1; this.size > t; t <<= 1) { power++ } // Calculate initial step's width: // * If we are full radix-4 - it is 2x smaller to give inital len=8 // * Otherwise it is the same as `power` to give len=4 this._width = power % 2 === 0 ? power - 1 : power // Pre-compute bit-reversal patterns this._bitrev = new Array(1 << this._width) for (let j = 0; j < this._bitrev.length; j++) { this._bitrev[j] = 0 for (let shift = 0; shift < this._width; shift += 2) { let revShift = this._width - shift - 2 this._bitrev[j] |= ((j >>> shift) & 3) << revShift } } this._out = null this._data = null this._inv = 0 } FFT.prototype.fromComplexArray = function fromComplexArray(complex, storage) { let res = storage || new Array(complex.length >>> 1) for (let i = 0; i < complex.length; i += 2) { res[i >>> 1] = complex[i] } return res } FFT.prototype.createComplexArray = function createComplexArray() { const res = new Array(this._csize) for (let i = 0; i < res.length; i++) { res[i] = 0 } return res } FFT.prototype.toComplexArray = function toComplexArray(input, storage) { let res = storage || this.createComplexArray() for (let i = 0; i < res.length; i += 2) { res[i] = input[i >>> 1] res[i + 1] = 0 } return res } FFT.prototype.completeSpectrum = function completeSpectrum(spectrum) { let size = this._csize let half = size >>> 1 for (let i = 2; i < half; i += 2) { spectrum[size - i] = spectrum[i] spectrum[size - i + 1] = -spectrum[i + 1] } } FFT.prototype.transform = function transform(out, data) { if (out === data) { throw new Error('Input and output buffers must be different') } this._out = out this._data = data this._inv = 0 this._transform4() this._out = null this._data = null } FFT.prototype.realTransform = function realTransform(out, data) { if (out === data) { throw new Error('Input and output buffers must be different') } this._out = out this._data = data this._inv = 0 this._realTransform4() this._out = null this._data = null } FFT.prototype.inverseTransform = function inverseTransform(out, data) { if (out === data) { throw new Error('Input and output buffers must be different') } this._out = out this._data = data this._inv = 1 this._transform4() for (let i = 0; i < out.length; i++) { out[i] /= this.size } this._out = null this._data = null } // radix-4 implementation // // NOTE: Uses of `var` are intentional for older V8 version that do not // support both `let compound assignments` and `const phi` FFT.prototype._transform4 = function _transform4() { let out = this._out let size = this._csize // Initial step (permute and transform) let width = this._width let step = 1 << width let len = (size / step) << 1 let outOff let t let bitrev = this._bitrev if (len === 4) { for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { const off = bitrev[t] this._singleTransform2(outOff, off, step) } } else { // len === 8 for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { const off = bitrev[t] this._singleTransform4(outOff, off, step) } } // Loop through steps in decreasing order let inv = this._inv ? -1 : 1 let table = this.table for (step >>= 2; step >= 2; step >>= 2) { len = (size / step) << 1 let quarterLen = len >>> 2 // Loop through offsets in the data for (outOff = 0; outOff < size; outOff += len) { // Full case let limit = outOff + quarterLen for (let i = outOff, k = 0; i < limit; i += 2, k += step) { const A = i const B = A + quarterLen const C = B + quarterLen const D = C + quarterLen // Original values const Ar = out[A] const Ai = out[A + 1] const Br = out[B] const Bi = out[B + 1] const Cr = out[C] const Ci = out[C + 1] const Dr = out[D] const Di = out[D + 1] // Middle values const MAr = Ar const MAi = Ai const tableBr = table[k] const tableBi = inv * table[k + 1] const MBr = Br * tableBr - Bi * tableBi const MBi = Br * tableBi + Bi * tableBr const tableCr = table[2 * k] const tableCi = inv * table[2 * k + 1] const MCr = Cr * tableCr - Ci * tableCi const MCi = Cr * tableCi + Ci * tableCr const tableDr = table[3 * k] const tableDi = inv * table[3 * k + 1] const MDr = Dr * tableDr - Di * tableDi const MDi = Dr * tableDi + Di * tableDr // Pre-Final values const T0r = MAr + MCr const T0i = MAi + MCi const T1r = MAr - MCr const T1i = MAi - MCi const T2r = MBr + MDr const T2i = MBi + MDi const T3r = inv * (MBr - MDr) const T3i = inv * (MBi - MDi) // Final values const FAr = T0r + T2r const FAi = T0i + T2i const FCr = T0r - T2r const FCi = T0i - T2i const FBr = T1r + T3i const FBi = T1i - T3r const FDr = T1r - T3i const FDi = T1i + T3r out[A] = FAr out[A + 1] = FAi out[B] = FBr out[B + 1] = FBi out[C] = FCr out[C + 1] = FCi out[D] = FDr out[D + 1] = FDi } } } } // radix-2 implementation // // NOTE: Only called for len=4 FFT.prototype._singleTransform2 = function _singleTransform2(outOff, off, step) { const out = this._out const data = this._data const evenR = data[off] const evenI = data[off + 1] const oddR = data[off + step] const oddI = data[off + step + 1] const leftR = evenR + oddR const leftI = evenI + oddI const rightR = evenR - oddR const rightI = evenI - oddI out[outOff] = leftR out[outOff + 1] = leftI out[outOff + 2] = rightR out[outOff + 3] = rightI } // radix-4 // // NOTE: Only called for len=8 FFT.prototype._singleTransform4 = function _singleTransform4(outOff, off, step) { const out = this._out const data = this._data const inv = this._inv ? -1 : 1 const step2 = step * 2 const step3 = step * 3 // Original values const Ar = data[off] const Ai = data[off + 1] const Br = data[off + step] const Bi = data[off + step + 1] const Cr = data[off + step2] const Ci = data[off + step2 + 1] const Dr = data[off + step3] const Di = data[off + step3 + 1] // Pre-Final values const T0r = Ar + Cr const T0i = Ai + Ci const T1r = Ar - Cr const T1i = Ai - Ci const T2r = Br + Dr const T2i = Bi + Di const T3r = inv * (Br - Dr) const T3i = inv * (Bi - Di) // Final values const FAr = T0r + T2r const FAi = T0i + T2i const FBr = T1r + T3i const FBi = T1i - T3r const FCr = T0r - T2r const FCi = T0i - T2i const FDr = T1r - T3i const FDi = T1i + T3r out[outOff] = FAr out[outOff + 1] = FAi out[outOff + 2] = FBr out[outOff + 3] = FBi out[outOff + 4] = FCr out[outOff + 5] = FCi out[outOff + 6] = FDr out[outOff + 7] = FDi } // Real input radix-4 implementation FFT.prototype._realTransform4 = function _realTransform4() { let out = this._out let size = this._csize // Initial step (permute and transform) let width = this._width let step = 1 << width let len = (size / step) << 1 let outOff let t let bitrev = this._bitrev if (len === 4) { for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { const off = bitrev[t] this._singleRealTransform2(outOff, off >>> 1, step >>> 1) } } else { // len === 8 for (outOff = 0, t = 0; outOff < size; outOff += len, t++) { const off = bitrev[t] this._singleRealTransform4(outOff, off >>> 1, step >>> 1) } } // Loop through steps in decreasing order let inv = this._inv ? -1 : 1 let table = this.table for (step >>= 2; step >= 2; step >>= 2) { len = (size / step) << 1 let halfLen = len >>> 1 let quarterLen = halfLen >>> 1 let hquarterLen = quarterLen >>> 1 // Loop through offsets in the data for (outOff = 0; outOff < size; outOff += len) { for (let i = 0, k = 0; i <= hquarterLen; i += 2, k += step) { let A = outOff + i let B = A + quarterLen let C = B + quarterLen let D = C + quarterLen // Original values let Ar = out[A] let Ai = out[A + 1] let Br = out[B] let Bi = out[B + 1] let Cr = out[C] let Ci = out[C + 1] let Dr = out[D] let Di = out[D + 1] // Middle values let MAr = Ar let MAi = Ai let tableBr = table[k] let tableBi = inv * table[k + 1] let MBr = Br * tableBr - Bi * tableBi let MBi = Br * tableBi + Bi * tableBr let tableCr = table[2 * k] let tableCi = inv * table[2 * k + 1] let MCr = Cr * tableCr - Ci * tableCi let MCi = Cr * tableCi + Ci * tableCr let tableDr = table[3 * k] let tableDi = inv * table[3 * k + 1] let MDr = Dr * tableDr - Di * tableDi let MDi = Dr * tableDi + Di * tableDr // Pre-Final values let T0r = MAr + MCr let T0i = MAi + MCi let T1r = MAr - MCr let T1i = MAi - MCi let T2r = MBr + MDr let T2i = MBi + MDi let T3r = inv * (MBr - MDr) let T3i = inv * (MBi - MDi) // Final values let FAr = T0r + T2r let FAi = T0i + T2i let FBr = T1r + T3i let FBi = T1i - T3r out[A] = FAr out[A + 1] = FAi out[B] = FBr out[B + 1] = FBi // Output final middle point if (i === 0) { let FCr = T0r - T2r let FCi = T0i - T2i out[C] = FCr out[C + 1] = FCi continue } // Do not overwrite ourselves if (i === hquarterLen) { continue } // In the flipped case: // MAi = -MAi // MBr=-MBi, MBi=-MBr // MCr=-MCr // MDr=MDi, MDi=MDr let ST0r = T1r let ST0i = -T1i let ST1r = T0r let ST1i = -T0i let ST2r = -inv * T3i let ST2i = -inv * T3r let ST3r = -inv * T2i let ST3i = -inv * T2r let SFAr = ST0r + ST2r let SFAi = ST0i + ST2i let SFBr = ST1r + ST3i let SFBi = ST1i - ST3r let SA = outOff + quarterLen - i let SB = outOff + halfLen - i out[SA] = SFAr out[SA + 1] = SFAi out[SB] = SFBr out[SB + 1] = SFBi } } } } // radix-2 implementation // // NOTE: Only called for len=4 FFT.prototype._singleRealTransform2 = function _singleRealTransform2(outOff, off, step) { const out = this._out const data = this._data const evenR = data[off] const oddR = data[off + step] const leftR = evenR + oddR const rightR = evenR - oddR out[outOff] = leftR out[outOff + 1] = 0 out[outOff + 2] = rightR out[outOff + 3] = 0 } // radix-4 // // NOTE: Only called for len=8 FFT.prototype._singleRealTransform4 = function _singleRealTransform4(outOff, off, step) { const out = this._out const data = this._data const inv = this._inv ? -1 : 1 const step2 = step * 2 const step3 = step * 3 // Original values const Ar = data[off] const Br = data[off + step] const Cr = data[off + step2] const Dr = data[off + step3] // Pre-Final values const T0r = Ar + Cr const T1r = Ar - Cr const T2r = Br + Dr const T3r = inv * (Br - Dr) // Final values const FAr = T0r + T2r const FBr = T1r const FBi = -T3r const FCr = T0r - T2r const FDr = T1r const FDi = T3r out[outOff] = FAr out[outOff + 1] = 0 out[outOff + 2] = FBr out[outOff + 3] = FBi out[outOff + 4] = FCr out[outOff + 5] = 0 out[outOff + 6] = FDr out[outOff + 7] = FDi } export default FFT ================================================ FILE: src/renderer/plugins/player/pitch-shifter/ola-processor.js ================================================ /* eslint-disable no-var */ const WEBAUDIO_BLOCK_SIZE = 128 /** Overlap-Add Node */ class OLAProcessor extends globalThis.AudioWorkletProcessor { constructor(options) { super(options) this.keepReturnTrue = true this.processNow = false this.nbInputs = options.numberOfInputs this.nbOutputs = options.numberOfOutputs this.blockSize = options.processorOptions.blockSize // TODO for now, the only support hop size is the size of a web audio block this.hopSize = WEBAUDIO_BLOCK_SIZE this.nbOverlaps = this.blockSize / this.hopSize this.lastSilencedHopCount = 0 this.nbOverlaps2x = this.nbOverlaps * 2 this.fakeEmptyInputs = [new Array(2).fill(new Float32Array(WEBAUDIO_BLOCK_SIZE))] // pre-allocate input buffers (will be reallocated if needed) this.inputBuffers = new Array(this.nbInputs) this.inputBuffersHead = new Array(this.nbInputs) this.inputBuffersToSend = new Array(this.nbInputs) // assume 2 channels per input for (var i = 0; i < this.nbInputs; i++) { this.allocateInputChannels(i, 2) } // pre-allocate input buffers (will be reallocated if needed) this.outputBuffers = new Array(this.nbOutputs) this.outputBuffersToRetrieve = new Array(this.nbOutputs) // assume 2 channels per output for (i = 0; i < this.nbOutputs; i++) { this.allocateOutputChannels(i, 2) } this.port.onmessage = (e) => this.keepReturnTrue = false } /** Handles dynamic reallocation of input/output channels buffer (channel numbers may vary during lifecycle) **/ reallocateChannelsIfNeeded(inputs, outputs, force) { for (var i = 0; i < this.nbInputs; i++) { let nbChannels = inputs[i].length if (force || (nbChannels != this.inputBuffers[i].length)) { this.allocateInputChannels(i, nbChannels) // console.log("reallocateChannelsIfNeeded"); } } for (i = 0; i < this.nbOutputs; i++) { let nbChannels = outputs[i].length if (force || (nbChannels != this.outputBuffers[i].length)) { this.allocateOutputChannels(i, nbChannels) // console.log("reallocateChannelsIfNeeded"); } } } allocateInputChannels(inputIndex, nbChannels) { // allocate input buffers // console.log("allocateInputChannels"); this.inputBuffers[inputIndex] = new Array(nbChannels) for (var i = 0; i < nbChannels; i++) { this.inputBuffers[inputIndex][i] = new Float32Array(this.blockSize + WEBAUDIO_BLOCK_SIZE) this.inputBuffers[inputIndex][i].fill(0) } // allocate input buffers to send and head pointers to copy from // (cannot directly send a pointer/subarray because input may be modified) this.inputBuffersHead[inputIndex] = new Array(nbChannels) this.inputBuffersToSend[inputIndex] = new Array(nbChannels) for (i = 0; i < nbChannels; i++) { this.inputBuffersHead[inputIndex][i] = this.inputBuffers[inputIndex][i].subarray(0, this.blockSize) this.inputBuffersToSend[inputIndex][i] = new Float32Array(this.blockSize) } } allocateOutputChannels(outputIndex, nbChannels) { // allocate output buffers this.outputBuffers[outputIndex] = new Array(nbChannels) for (var i = 0; i < nbChannels; i++) { this.outputBuffers[outputIndex][i] = new Float32Array(this.blockSize) this.outputBuffers[outputIndex][i].fill(0) } // allocate output buffers to retrieve // (cannot send a pointer/subarray because new output has to be add to existing output) this.outputBuffersToRetrieve[outputIndex] = new Array(nbChannels) for (i = 0; i < nbChannels; i++) { this.outputBuffersToRetrieve[outputIndex][i] = new Float32Array(this.blockSize) this.outputBuffersToRetrieve[outputIndex][i].fill(0) } } checkForNotSilence(value) { return value !== 0 } /** Read next web audio block to input buffers **/ readInputs(inputs) { // when playback is paused, we may stop receiving new samples /* if (inputs[0].length && inputs[0][0].length == 0) { for (var i = 0; i < this.nbInputs; i++) { for (var j = 0; j < this.inputBuffers[i].length; j++) { this.inputBuffers[i][j].fill(0, this.blockSize); } } return; } */ for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < this.inputBuffers[i].length; j++) { let webAudioBlock = inputs[i][j] this.inputBuffers[i][j]?.set(webAudioBlock, this.blockSize) } } } /** Shift left content of input buffers to receive new web audio block **/ shiftInputBuffers() { for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < this.inputBuffers[i].length; j++) { this.inputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE) } } } /** Copy contents of input buffers to buffer actually sent to process **/ prepareInputBuffersToSend() { for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < this.inputBuffers[i].length; j++) { this.inputBuffersToSend[i][j].set(this.inputBuffersHead[i][j]) } } } /** Add contents of output buffers just processed to output buffers **/ handleOutputBuffersToRetrieve() { for (let i = 0; i < this.nbOutputs; i++) { for (let j = 0; j < this.outputBuffers[i].length; j++) { for (let k = 0; k < this.blockSize; k++) { this.outputBuffers[i][j][k] += this.outputBuffersToRetrieve[i][j][k] / this.nbOverlaps } } } } /** Write next web audio block from output buffers **/ writeOutputs(outputs) { for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < this.inputBuffers[i].length; j++) { let webAudioBlock = this.outputBuffers[i][j].subarray(0, WEBAUDIO_BLOCK_SIZE) outputs[i][j]?.set(webAudioBlock) } } } /** Shift left content of output buffers to receive new web audio block **/ shiftOutputBuffers() { for (let i = 0; i < this.nbOutputs; i++) { for (let j = 0; j < this.outputBuffers[i].length; j++) { this.outputBuffers[i][j].copyWithin(0, WEBAUDIO_BLOCK_SIZE) this.outputBuffers[i][j].subarray(this.blockSize - WEBAUDIO_BLOCK_SIZE).fill(0) } } } process(inputs, outputs, params) { // console.log(inputs[0].length ? "active" : "inactive"); // this.reallocateChannelsIfNeeded(inputs, outputs); // if (inputs[0][0].some(this.checkForNotSilence) || inputs[0][1].some(this.checkForNotSilence)) // console.log(inputs[0].length) if (inputs[0].length < 2) { // DUE TO CHROME BUG/INCONSISTENCY, WHEN INACTIVE SILENT NODE IS CONNECTED, inputs[0] IS EITHER EMPTY OR CONTAINS 1 CHANNEL OF SILENT AUDIO DATA, REQUIRES SPECIAL HANDLING // if (inputs[0][0].some(this.checkForNotSilence)) console.warn("single channel not silence exception!"); if (this.lastSilencedHopCount < this.nbOverlaps2x) { // ALLOW nbOverlaps2x BLOCKS OF SILENCE TO COME THROUGH TO ACCOMODATE LATENCY TAIL this.lastSilencedHopCount++ inputs = this.fakeEmptyInputs this.processNow = true } else { // console.warn("skipping processing"); if (this.lastSilencedHopCount === this.nbOverlaps2x) { this.lastSilencedHopCount++ this.reallocateChannelsIfNeeded(this.fakeEmptyInputs, outputs, true) // console.warn("reallocateChannels"); } this.processNow = false // ENABLES SKIPPING UNNEEDED PROCESSING OF SILENT INPUT } } else { if (this.lastSilencedHopCount) { this.lastSilencedHopCount = 0 // this.reallocateChannelsIfNeeded(inputs, outputs, true); // console.warn("reallocateChannels"); } this.processNow = true } if (this.processNow) { this.readInputs(inputs) this.shiftInputBuffers() this.prepareInputBuffersToSend() this.processOLA(this.inputBuffersToSend, this.outputBuffersToRetrieve, params) this.handleOutputBuffersToRetrieve() this.writeOutputs(outputs) this.shiftOutputBuffers() } return this.keepReturnTrue } /* processOLA(inputs, outputs, params) { console.assert(false, "Not overriden"); } */ } export default OLAProcessor ================================================ FILE: src/renderer/plugins/player/pitch-shifter/phase-vocoder.js ================================================ // https://github.com/olvb/phaze/issues/26#issuecomment-1573938170 // https://github.com/olvb/phaze import FFT from './fft' import OLAProcessor from './ola-processor' const DEFAULT_BUFFERED_BLOCK_SIZE = 4096 function genHannWindow(length) { let win = new Float32Array(length) for (let i = 0; i < length; i++) { win[i] = 0.8 * (1 - Math.cos(2 * Math.PI * i / length)) } return win } class PhaseVocoderProcessor extends OLAProcessor { static get parameterDescriptors() { return [{ name: 'pitchFactor', defaultValue: 1.0, automationRate: 'k-rate', }, /* , { name: 'pitchCents', defaultValue: 0.0, automationRate: 'k-rate' } */] } constructor(options) { (options.processorOptions ??= {}).blockSize ??= DEFAULT_BUFFERED_BLOCK_SIZE super(options) this.fftSize = this.blockSize this.timeCursor = 0 this.hannWindow = genHannWindow(this.blockSize) // prepare FFT and pre-allocate buffers this.fft = new FFT(this.fftSize) this.freqComplexBuffer = this.fft.createComplexArray() this.freqComplexBufferShifted = this.fft.createComplexArray() this.timeComplexBuffer = this.fft.createComplexArray() this.magnitudes = new Float32Array(this.fftSize / 2 + 1) this.peakIndexes = new Int32Array(this.magnitudes.length) this.nbPeaks = 0 } processOLA(inputs, outputs, parameters) { // k-rate automation, param arrays only have single value const pitchFactor = parameters.pitchFactor[0]/* || Math.pow(2, (parameters.pitchCents[0]/12)) */ for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < inputs[i].length; j++) { // big assumption here: output is symetric to input let input = inputs[i][j] let output = outputs[i][j] this.applyHannWindow(input) this.fft.realTransform(this.freqComplexBuffer, input) this.computeMagnitudes() this.findPeaks() this.shiftPeaks(pitchFactor) this.fft.completeSpectrum(this.freqComplexBufferShifted) this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted) this.fft.fromComplexArray(this.timeComplexBuffer, output) this.applyHannWindow(output) } } this.timeCursor += this.hopSize } /** Apply Hann window in-place */ applyHannWindow(input) { for (let i = 0; i < this.blockSize; i++) { input[i] = input[i] * this.hannWindow[i] } } /** Compute squared magnitudes for peak finding **/ computeMagnitudes() { let i = 0; let j = 0 while (i < this.magnitudes.length) { let real = this.freqComplexBuffer[j] let imag = this.freqComplexBuffer[j + 1] // no need to sqrt for peak finding this.magnitudes[i] = real ** 2 + imag ** 2 i += 1 j += 2 } } /** Find peaks in spectrum magnitudes **/ findPeaks() { this.nbPeaks = 0 let i = 2 let end = this.magnitudes.length - 2 while (i < end) { let mag = this.magnitudes[i] if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) { i++ continue } if (this.magnitudes[i + 1] >= mag || this.magnitudes[i + 2] >= mag) { i++ continue } this.peakIndexes[this.nbPeaks] = i this.nbPeaks++ i += 2 } } /** Shift peaks and regions of influence by pitchFactor into new specturm */ shiftPeaks(pitchFactor) { // zero-fill new spectrum this.freqComplexBufferShifted.fill(0) for (let i = 0; i < this.nbPeaks; i++) { let peakIndex = this.peakIndexes[i] let peakIndexShifted = Math.round(peakIndex * pitchFactor) if (peakIndexShifted > this.magnitudes.length) break // find region of influence let startIndex = 0 let endIndex = this.fftSize if (i > 0) { startIndex = peakIndex - Math.floor((peakIndex - this.peakIndexes[i - 1]) / 2) } if (i < this.nbPeaks - 1) { endIndex = peakIndex + Math.ceil((this.peakIndexes[i + 1] - peakIndex) / 2) } // shift whole region of influence around peak to shifted peak let startOffset = startIndex - peakIndex let endOffset = endIndex - peakIndex for (let j = startOffset; j < endOffset; j++) { let binIndex = peakIndex + j let binIndexShifted = peakIndexShifted + j if (binIndexShifted >= this.magnitudes.length) break // apply phase correction let omegaDelta = 2 * Math.PI * (binIndexShifted - binIndex) / this.fftSize let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor) let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor) let indexReal = binIndex * 2 let indexImag = indexReal + 1 let valueReal = this.freqComplexBuffer[indexReal] let valueImag = this.freqComplexBuffer[indexImag] let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal let indexShiftedReal = binIndexShifted * 2 let indexShiftedImag = indexShiftedReal + 1 this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag } } } } globalThis.registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor) ================================================ FILE: src/renderer/router.ts ================================================ /* eslint-disable @typescript-eslint/no-var-requires */ // import Vue from 'vue' import { createRouter, createWebHashHistory } from 'vue-router' const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/search', name: 'Search', component: require('./views/Search/index.vue').default, meta: { name: 'Search', }, }, { path: '/songList/list', name: 'SongList', component: require('./views/songList/List/index.vue').default, meta: { name: 'SongList', }, }, { path: '/songList/detail', name: 'SongListDetail', component: require('./views/songList/Detail/index.vue').default, meta: { name: 'SongList', }, }, { path: '/leaderboard', name: 'Leaderboard', component: require('./views/Leaderboard/index.vue').default, meta: { name: 'Leaderboard', }, }, { path: '/list', name: 'List', component: require('./views/List/index.vue').default, meta: { name: 'List', }, }, { path: '/download', name: 'Download', component: require('./views/Download/index.vue').default, meta: { name: 'Download', }, }, { path: '/setting', name: 'Setting', component: require('./views/Setting/index.vue').default, meta: { name: 'Setting', }, }, { path: '/:pathMatch(.*)*', redirect: '/search' }, ], linkActiveClass: 'active-link', linkExactActiveClass: 'exact-active-link', }) export default router ================================================ FILE: src/renderer/store/dislikeList/action.ts ================================================ import { markRaw } from '@common/utils/vueTools' import { dislikeInfo, dislikeRuleCount } from './state' import { SPLIT_CHAR } from '@common/constants' export const hasDislike = (info: LX.Music.MusicInfo | LX.Download.ListItem) => { if ('progress' in info) info = info.metadata.musicInfo const name = info.name?.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() ?? '' const singer = info.singer?.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() ?? '' return dislikeInfo.musicNames.has(name) || dislikeInfo.singerNames.has(singer) || dislikeInfo.names.has(`${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}`) } export const initDislikeInfo = ({ musicNames, rules, names, singerNames }: LX.Dislike.DislikeInfo) => { dislikeInfo.names = markRaw(names) dislikeInfo.singerNames = markRaw(singerNames) dislikeInfo.musicNames = markRaw(musicNames) dislikeInfo.rules = rules dislikeRuleCount.value = dislikeInfo.musicNames.size + dislikeInfo.singerNames.size + dislikeInfo.names.size } const initNameSet = () => { dislikeInfo.names.clear() dislikeInfo.musicNames.clear() dislikeInfo.singerNames.clear() const list: string[] = [] for (const item of dislikeInfo.rules.split('\n')) { if (!item) continue let [name, singer] = item.split(SPLIT_CHAR.DISLIKE_NAME) if (name) { name = name.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() const rule = `${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}` dislikeInfo.names.add(rule) list.push(rule) } else { dislikeInfo.musicNames.add(name) list.push(name) } } else if (singer) { singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() dislikeInfo.singerNames.add(singer) list.push(`${SPLIT_CHAR.DISLIKE_NAME}${singer}`) } } dislikeInfo.rules = Array.from(new Set(list)).join('\n') dislikeRuleCount.value = dislikeInfo.musicNames.size + dislikeInfo.singerNames.size + dislikeInfo.names.size } export const addDislikeInfo = (infos: LX.Dislike.DislikeMusicInfo[]) => { dislikeInfo.rules += '\n' + infos.map(info => `${info.name ?? ''}${SPLIT_CHAR.DISLIKE_NAME}${info.singer ?? ''}`).join('\n') initNameSet() return dislikeInfo.rules } export const overwirteDislikeInfo = (rules: string) => { dislikeInfo.rules = rules initNameSet() return dislikeInfo.rules } export const clearDislikeInfo = () => { dislikeInfo.rules = '' initNameSet() return dislikeInfo.rules } // export const updateDislikeInfo = (info: LX.Dislike.ListItem) => { // const targetInfo = dislikeInfo.list.find(i => i.id == info.id) // if (!targetInfo) return // targetInfo.name = info.name // targetInfo.singer = info.singer // initNameSet() // } // export const removeDislikeInfo = (ids: string[]) => { // for (const id of ids) { // dislikeInfo.list.splice(dislikeInfo.list.findIndex(info => info.id == id), 1) // } // initNameSet() // } // export const clearDislikeInfo = () => { // dislikeInfo.rules = '' // initNameSet() // } ================================================ FILE: src/renderer/store/dislikeList/index.ts ================================================ export * as action from './action' export * from './state' ================================================ FILE: src/renderer/store/dislikeList/state.ts ================================================ import { markRaw, ref } from '@common/utils/vueTools' // import { deduplicationList } from '@common/utils/renderer' export const dislikeInfo: LX.Dislike.DislikeInfo = markRaw({ names: markRaw(new Set()), musicNames: markRaw(new Set()), singerNames: markRaw(new Set()), rules: '', }) export const dislikeRuleCount = ref(0) ================================================ FILE: src/renderer/store/download/action.ts ================================================ import { downloadTasksGet, // downloadListClear, downloadTasksCreate, downloadTasksRemove, downloadTasksUpdate, } from '@renderer/utils/ipc' import { downloadList, } from './state' import { markRaw, toRaw } from '@common/utils/vueTools' import { getMusicUrl, getPicUrl, getLyricInfo } from '@renderer/core/music/online' import { appSetting } from '../setting' import { qualityList } from '..' import { proxyCallback } from '@renderer/worker/utils' import { arrPush, arrUnshift, joinPath } from '@renderer/utils' import { DOWNLOAD_STATUS } from '@common/constants' import { proxy } from '../index' import { buildSavePath } from './utils' const waitingUpdateTasks = new Map() let timer: NodeJS.Timeout | null = null const throttleUpdateTask = (tasks: LX.Download.ListItem[]) => { for (const task of tasks) waitingUpdateTasks.set(task.id, toRaw(task)) if (timer) return timer = setTimeout(() => { timer = null void downloadTasksUpdate(Array.from(waitingUpdateTasks.values())) waitingUpdateTasks.clear() }, 100) } const runingTask = new Map() // const initDownloadList = (list: LX.Download.ListItem[]) => { // downloadList.splice(0, downloadList.length, ...list) // } export const getDownloadList = async(): Promise => { if (!downloadList.length) { const list = await downloadTasksGet() for (const downloadInfo of list) { markRaw(downloadInfo.metadata) switch (downloadInfo.status) { case DOWNLOAD_STATUS.RUN: case DOWNLOAD_STATUS.WAITING: downloadInfo.status = DOWNLOAD_STATUS.PAUSE downloadInfo.statusText = window.i18n.t('download___status_paused') default: break } } arrPush(downloadList, list) } return downloadList } const addTasks = async(list: LX.Download.ListItem[]) => { const addMusicLocationType = appSetting['list.addMusicLocationType'] await downloadTasksCreate(list.map(i => toRaw(i)), addMusicLocationType) if (addMusicLocationType === 'top') { arrUnshift(downloadList, list) } else { arrPush(downloadList, list) } window.app_event.downloadListUpdate() } const setStatusText = (downloadInfo: LX.Download.ListItem, text: string) => { // 设置状态文本 downloadInfo.statusText = text throttleUpdateTask([downloadInfo]) } const setUrl = (downloadInfo: LX.Download.ListItem, url: string) => { downloadInfo.metadata.url = url throttleUpdateTask([downloadInfo]) } const updateFilePath = (downloadInfo: LX.Download.ListItem, filePath: string) => { downloadInfo.metadata.filePath = filePath throttleUpdateTask([downloadInfo]) } const setProgress = (downloadInfo: LX.Download.ListItem, progress: LX.Download.ProgressInfo) => { downloadInfo.total = progress.total downloadInfo.downloaded = progress.downloaded downloadInfo.writeQueue = progress.writeQueue if (progress.progress == 100) { downloadInfo.speed = '' downloadInfo.progress = 99.99 setStatusText(downloadInfo, window.i18n.t('download_status_write_queue', { num: progress.writeQueue })) } else { downloadInfo.speed = progress.speed downloadInfo.progress = progress.progress } throttleUpdateTask([downloadInfo]) } const setStatus = (downloadInfo: LX.Download.ListItem, status: LX.Download.DownloadTaskStatus, statusText?: string) => { // 设置状态及状态文本 if (statusText == null) { switch (status) { case DOWNLOAD_STATUS.RUN: statusText = window.i18n.t('download___status_running') break case DOWNLOAD_STATUS.WAITING: statusText = window.i18n.t('download___status_waiting') break case DOWNLOAD_STATUS.PAUSE: statusText = window.i18n.t('download___status_paused') break case DOWNLOAD_STATUS.ERROR: statusText = window.i18n.t('download___status_error') break case DOWNLOAD_STATUS.COMPLETED: statusText = window.i18n.t('download___status_completed') break default: statusText = '' break } } if (downloadInfo.statusText == statusText && downloadInfo.status == status) return if (status == DOWNLOAD_STATUS.COMPLETED) downloadInfo.isComplate = true downloadInfo.statusText = statusText downloadInfo.status = status throttleUpdateTask([downloadInfo]) } // 修复 1.1.x版本 酷狗源歌词格式 const fixKgLyric = (lrc: string) => /\[00:\d\d:\d\d.\d+\]/.test(lrc) ? lrc.replace(/(?:\[00:(\d\d:\d\d.\d+\]))/gm, '[$1') : lrc const getProxy = () => { return proxy.enable && proxy.host ? { host: proxy.host, port: parseInt(proxy.port || '80'), } : proxy.envProxy ? { host: proxy.envProxy.host, port: parseInt(proxy.envProxy.port || '80'), } : undefined } /** * 设置歌曲meta信息 * @param downloadInfo 下载任务信息 */ const saveMeta = (downloadInfo: LX.Download.ListItem) => { if (downloadInfo.metadata.quality === 'ape') return const isUseOtherSource = appSetting['download.isUseOtherSource'] const tasks: [Promise, Promise] = [ appSetting['download.isEmbedPic'] ? downloadInfo.metadata.musicInfo.meta.picUrl ? Promise.resolve(downloadInfo.metadata.musicInfo.meta.picUrl) : getPicUrl({ musicInfo: downloadInfo.metadata.musicInfo, isRefresh: false, allowToggleSource: isUseOtherSource }).catch(err => { console.log(err) return null }) : Promise.resolve(null), appSetting['download.isEmbedLyric'] ? getLyricInfo({ musicInfo: downloadInfo.metadata.musicInfo, isRefresh: false, allowToggleSource: isUseOtherSource }).catch(err => { console.log(err) return null }) : Promise.resolve(null), ] void Promise.all(tasks).then(([imgUrl, lyrics]) => { const info = { filePath: downloadInfo.metadata.filePath, isEmbedLyricLx: appSetting['download.isEmbedLyricLx'], isEmbedLyricT: appSetting['download.isEmbedLyricT'], isEmbedLyricR: appSetting['download.isEmbedLyricR'], title: downloadInfo.metadata.musicInfo.name, artist: downloadInfo.metadata.musicInfo.singer?.replaceAll('、', ';'), album: downloadInfo.metadata.musicInfo.meta.albumName, APIC: imgUrl, } void window.lx.worker.download.writeMeta(info, lyrics ?? { lyric: '' }, getProxy()) }) } /** * 保存歌词文件 * @param downloadInfo 下载任务信息 */ const downloadLyric = (downloadInfo: LX.Download.ListItem) => { if (!appSetting['download.isDownloadLrc']) return void getLyricInfo({ musicInfo: downloadInfo.metadata.musicInfo, isRefresh: false, allowToggleSource: appSetting['download.isUseOtherSource'], }).then(lrcs => { if (lrcs.lyric) { lrcs.lyric = fixKgLyric(lrcs.lyric) const info = { filePath: downloadInfo.metadata.filePath.substring(0, downloadInfo.metadata.filePath.lastIndexOf('.')) + '.lrc', format: appSetting['download.lrcFormat'], downloadLxlrc: appSetting['download.isDownloadLxLrc'], downloadTlrc: appSetting['download.isDownloadTLrc'], downloadRlrc: appSetting['download.isDownloadRLrc'], } void window.lx.worker.download.saveLrc(lrcs, info) } }) } const getUrl = async(downloadInfo: LX.Download.ListItem, isRefresh: boolean = false) => { let toggleMusicInfo = downloadInfo.metadata.musicInfo.meta.toggleMusicInfo return (toggleMusicInfo ? getMusicUrl({ musicInfo: toggleMusicInfo, isRefresh, quality: downloadInfo.metadata.quality, allowToggleSource: false, }) : Promise.reject(new Error('not found'))).catch(() => { return getMusicUrl({ musicInfo: downloadInfo.metadata.musicInfo, isRefresh: false, quality: downloadInfo.metadata.quality, allowToggleSource: appSetting['download.isUseOtherSource'], }) }).catch(() => '') } const handleRefreshUrl = (downloadInfo: LX.Download.ListItem) => { setStatusText(downloadInfo, window.i18n.t('download_status_error_refresh_url')) let toggleMusicInfo = downloadInfo.metadata.musicInfo.meta.toggleMusicInfo ;(toggleMusicInfo ? getMusicUrl({ musicInfo: toggleMusicInfo, isRefresh: true, quality: downloadInfo.metadata.quality, allowToggleSource: false, }) : Promise.reject(new Error('not found'))).catch(() => { return getMusicUrl({ musicInfo: downloadInfo.metadata.musicInfo, isRefresh: true, quality: downloadInfo.metadata.quality, allowToggleSource: appSetting['download.isUseOtherSource'], }) }) .catch(() => '') .then(url => { // commit('setStatusText', { downloadInfo, text: '链接刷新成功' }) setUrl(downloadInfo, url) void window.lx.worker.download.updateUrl(downloadInfo.id, url) }) .catch(err => { console.log(err) handleError(downloadInfo, err.message) }) } const handleError = (downloadInfo: LX.Download.ListItem, message?: string) => { setStatus(downloadInfo, DOWNLOAD_STATUS.ERROR, message) void window.lx.worker.download.removeTask(downloadInfo.id) runingTask.delete(downloadInfo.id) void checkStartTask() } const handleStartTask = async(downloadInfo: LX.Download.ListItem) => { if (!downloadInfo.metadata.url) { setStatusText(downloadInfo, window.i18n.t('download_status_url_getting')) const url = await getUrl(downloadInfo) if (!url) { handleError(downloadInfo, window.i18n.t('download_status_error_url_failed')) return } setUrl(downloadInfo, url) if (downloadInfo.status != DOWNLOAD_STATUS.RUN) return } const savePath = buildSavePath(downloadInfo) const filePath = joinPath(savePath, downloadInfo.metadata.fileName) if (downloadInfo.metadata.filePath != filePath) updateFilePath(downloadInfo, filePath) setStatusText(downloadInfo, window.i18n.t('download_status_start')) await window.lx.worker.download.startTask(toRaw(downloadInfo), savePath, appSetting['download.skipExistFile'], proxyCallback((event: LX.Download.DownloadTaskActions) => { // console.log(event) switch (event.action) { case 'start': setStatus(downloadInfo, DOWNLOAD_STATUS.RUN) break case 'complete': downloadInfo.progress = 100 saveMeta(downloadInfo) downloadLyric(downloadInfo) void window.lx.worker.download.removeTask(downloadInfo.id) runingTask.delete(downloadInfo.id) setStatus(downloadInfo, DOWNLOAD_STATUS.COMPLETED) void checkStartTask() break case 'refreshUrl': handleRefreshUrl(downloadInfo) break case 'statusText': setStatusText(downloadInfo, event.data) break case 'progress': setProgress(downloadInfo, event.data) break case 'error': handleError(downloadInfo, event.data.error ? window.i18n.t(event.data.error) + (event.data.message ?? '') : event.data.message, ) break default: break } }), getProxy()) } const startTask = async(downloadInfo: LX.Download.ListItem) => { setStatus(downloadInfo, DOWNLOAD_STATUS.RUN) runingTask.set(downloadInfo.id, downloadInfo) void handleStartTask(downloadInfo) } const getStartTask = (list: LX.Download.ListItem[]): LX.Download.ListItem | null => { let downloadCount = 0 const waitList = list.filter(item => { if (item.status == DOWNLOAD_STATUS.WAITING) return true if (item.status == DOWNLOAD_STATUS.RUN) ++downloadCount return false }) // console.log(downloadCount, waitList) return downloadCount < appSetting['download.maxDownloadNum'] ? waitList.shift() ?? null : null } const checkStartTask = async() => { if (runingTask.size >= appSetting['download.maxDownloadNum']) return let result = getStartTask(downloadList) // console.log(result) while (result) { await startTask(result) result = getStartTask(downloadList) } } /** * 过滤重复任务 * @param list */ const filterTask = (list: LX.Download.ListItem[]) => { const set = new Set() for (const item of downloadList) set.add(item.id) return list.filter(item => { if (set.has(item.id)) return false markRaw(item.metadata) set.add(item.id) return true }) } /** * 创建下载任务 * @param list 要下载的歌曲 * @param quality 下载音质 */ export const createDownloadTasks = async(list: LX.Music.MusicInfoOnline[], quality: LX.Quality, listId?: string) => { if (!list.length) return const tasks = filterTask(await window.lx.worker.download.createDownloadTasks(list, quality, appSetting['download.fileName'], toRaw(qualityList.value), listId), ) if (tasks.length) await addTasks(tasks) void checkStartTask() } /** * 开始下载任务 * @param list */ export const startDownloadTasks = async(list: LX.Download.ListItem[]) => { for (const downloadInfo of list) { switch (downloadInfo.status) { case DOWNLOAD_STATUS.PAUSE: case DOWNLOAD_STATUS.ERROR: if (runingTask.size < appSetting['download.maxDownloadNum']) void startTask(downloadInfo) else setStatus(downloadInfo, DOWNLOAD_STATUS.WAITING) default: break } } void checkStartTask() } /** * 暂停下载任务 * @param list */ export const pauseDownloadTasks = async(list: LX.Download.ListItem[]) => { for (const downloadInfo of list) { switch (downloadInfo.status) { case DOWNLOAD_STATUS.RUN: void window.lx.worker.download.pauseTask(downloadInfo.id) runingTask.delete(downloadInfo.id) case DOWNLOAD_STATUS.WAITING: case DOWNLOAD_STATUS.ERROR: setStatus(downloadInfo, DOWNLOAD_STATUS.PAUSE) default: break } } void checkStartTask() } /** * 移除下载任务 * @param ids 要移除的任务Id */ export const removeDownloadTasks = async(ids: string[]) => { await downloadTasksRemove(ids) const idsSet = new Set(ids) const newList = downloadList.filter(task => { if (runingTask.has(task.id)) { void window.lx.worker.download.removeTask(task.id) runingTask.delete(task.id) } return !idsSet.has(task.id) }) downloadList.splice(0, downloadList.length) arrPush(downloadList, newList) void checkStartTask() window.app_event.downloadListUpdate() } ================================================ FILE: src/renderer/store/download/state.ts ================================================ import { reactive, ref, markRaw } from '@common/utils/vueTools' import { DOWNLOAD_STATUS } from '@common/constants' export const isInitedList = ref(false) export const setInited = () => { isInitedList.value = true } export const downloadList = reactive([]) // export const downloadListMap = new Map() export const downloadStatus = markRaw(DOWNLOAD_STATUS) ================================================ FILE: src/renderer/store/download/utils.ts ================================================ import { appSetting } from '@renderer/store/setting' import { defaultList, loveList, userLists } from '@renderer/store/list/listManage' import { filterFileName } from '@common/utils/common' import { clipFileNameLength } from '@common/utils/tools' import { joinPath } from '@common/utils/nodejs' export const buildSavePath = (musicInfo: LX.Download.ListItem) => { let savePath = appSetting['download.savePath'] if (appSetting['download.isSavePathGroupByListName']) { let dirName: string | undefined const listId = musicInfo.metadata.listId switch (listId) { case defaultList.id: dirName = window.i18n.t(defaultList.name) break case loveList.id: dirName = window.i18n.t(loveList.name) break default: dirName = userLists.find(list => list.id === listId)?.name break } if (dirName) dirName = filterFileName(dirName) savePath = joinPath(savePath, clipFileNameLength(dirName ?? window.i18n.t(defaultList.name))) } return savePath } ================================================ FILE: src/renderer/store/hotSearch.ts ================================================ import { reactive, markRaw } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' // import { deduplicationList } from '@common/utils/renderer' export type Source = LX.OnlineSource | 'all' interface SourceLists extends Partial> { 'all': string[] } export const sources: Source[] = markRaw([]) export const sourceList: SourceLists = markRaw({ all: reactive([]), }) for (const source of music.sources) { if (!music[source.id as LX.OnlineSource]?.hotSearch) continue sources.push(source.id as LX.OnlineSource) sourceList[source.id as LX.OnlineSource] = reactive([]) } sources.push('all') const setList = (source: LX.OnlineSource, list: string[]): string[] => { return sourceList[source] = list.slice(0, 20) } const setLists = (lists: Array<{ source: LX.OnlineSource, list: string[] }>): string[] => { let wordsMap = new Map() for (const { source, list } of lists) { if (!sourceList[source]?.length) sourceList[source] = list.slice(0, 20) for (let item of list) { item = item.trim() wordsMap.set(item, (wordsMap.get(item) ?? 0) + 1) } } const wordsMapArr = Array.from(wordsMap) wordsMapArr.sort((a, b) => a[0].localeCompare(b[0])) wordsMapArr.sort((a, b) => b[1] - a[1]) const words = wordsMapArr.map(item => item[0]) return sourceList.all = words.slice(0, sources.length * 10) } export const getList = async(source: Source): Promise => { if (source == 'all') { let task = [] for (const source of sources) { if (source == 'all') continue task.push( sourceList[source]?.length ? Promise.resolve({ source, list: sourceList[source] }) : (music[source]?.hotSearch.getList() ?? Promise.reject(new Error('source not found: ' + source))).catch((err: any) => { console.log(err) return { source, list: [] } }), ) } return Promise.all(task).then((results: any[]) => { return setLists(results) }) } else { if (sourceList[source]?.length) return Promise.resolve(sourceList[source]) if (!music[source]?.hotSearch) { setList(source, []) return Promise.resolve([]) } return music[source]?.hotSearch.getList().then(data => setList(source, data.list)) } } export const clearList = (source: Source) => { sourceList[source] = [] } ================================================ FILE: src/renderer/store/index.ts ================================================ import { ref, reactive, shallowRef, markRaw, computed, watch } from '@common/utils/vueTools' import { windowSizeList as configWindowSizeList } from '@common/config' import { appSetting } from './setting' import pkg from '../../../package.json' import { type ProgressInfo } from 'electron-updater' import music from '@renderer/utils/musicSdk' process.versions.app = pkg.version export const apiSource = ref(null) export const proxy: { enable: boolean host: string port: string envProxy?: { host: string port: string } } = { enable: false, host: '', port: '', } export const sync: { enable: boolean mode: LX.AppSetting['sync.mode'] isShowSyncMode: boolean isShowAuthCodeModal: boolean deviceName: string type: keyof LX.Sync.ModeTypes server: { port: string status: { status: boolean message: string address: string[] code: string devices: LX.Sync.ServerKeyInfo[] } } client: { host: string status: { status: boolean message: string address: string[] } } } = reactive({ enable: false, mode: 'server', isShowSyncMode: false, isShowAuthCodeModal: false, deviceName: '', type: 'list', server: { port: '', status: { status: false, message: '', address: [], code: '', devices: [], }, }, client: { host: '', status: { status: false, message: '', address: [], }, }, }) export const openAPI = reactive({ address: '', message: '', }) export const windowSizeActive = computed(() => { return windowSizeList.find(i => i.id === appSetting['common.windowSizeId']) ?? windowSizeList[0] }) export const getSourceI18nPrefix = () => { return appSetting['common.sourceNameType'] == 'real' ? 'source_' : 'source_alias_' } export const sourceNames = computed(() => { const prefix = getSourceI18nPrefix() const sourceNames: Record = { kw: 'kw', tx: 'tx', kg: 'kg', mg: 'mg', wy: 'wy', all: window.i18n.t(prefix + 'all' as any), } for (const { id } of music.sources) { sourceNames[id as LX.OnlineSource] = window.i18n.t(prefix + id as any) } return sourceNames }) export const windowSizeList = markRaw(configWindowSizeList) export const isShowPact = ref(false) export const versionInfo = window.lxData.versionInfo = reactive<{ version: string newVersion: { version: string desc: string history?: LX.VersionInfo[] } | null showModal: boolean isUnknown: boolean isLatest: boolean reCheck: boolean status: LX.UpdateStatus downloadProgress: ProgressInfo | null }>({ version: pkg.version, newVersion: null, showModal: false, reCheck: false, isUnknown: false, isLatest: false, status: 'checking', downloadProgress: null, }) export const userApi = reactive<{ list: LX.UserApi.UserApiInfo[] status: boolean message?: string apis: Partial }>({ list: [], status: false, message: 'initing', apis: {}, }) export const isShowChangeLog = ref(false) export const isFullscreen = ref(false) watch(isFullscreen, isFullscreen => { window.lx.rootOffset = window.dt || isFullscreen ? 0 : 8 }, { immediate: true }) export const themeShouldUseDarkColors = ref(window.shouldUseDarkColors) export const qualityList = shallowRef({}) export const setQualityList = (_qualityList: LX.QualityList) => { qualityList.value = _qualityList } export const themeId = ref('green') export const themeInfo: LX.ThemeInfo = { themes: [], userThemes: [], dataPath: '', } ================================================ FILE: src/renderer/store/leaderboard/action.ts ================================================ // import { getLeaderboardSetting } from '@renderer/utils/data' import { deduplicationList, toNewMusicInfo } from '@renderer/utils' import musicSdk from '@renderer/utils/musicSdk' import { markRaw, markRawList } from '@common/utils/vueTools' import { boards, type Board, listDetailInfo, type ListDetailInfo } from './state' const cache = new Map() export const setBoard = (board: Board, source: LX.OnlineSource) => { boards[source] = markRaw(board) } export const setListDetail = (result: ListDetailInfo, id: string, page: number) => { listDetailInfo.list = markRaw([...result.list]) listDetailInfo.id = id listDetailInfo.source = result.source if (page == 1 || (result.total && result.list.length)) listDetailInfo.total = result.total else listDetailInfo.total = result.limit * page listDetailInfo.limit = result.limit listDetailInfo.page = page if (result.list.length) listDetailInfo.noItemLabel = '' else if (page == 1) listDetailInfo.noItemLabel = window.i18n.t('no_item') } export const clearListDetail = () => { listDetailInfo.list = [] listDetailInfo.id = '' listDetailInfo.source = null listDetailInfo.total = 0 listDetailInfo.limit = 30 listDetailInfo.page = 1 listDetailInfo.key = null listDetailInfo.noItemLabel = '' } export const getBoardsList = async(source: LX.OnlineSource) => { // const source = (await getLeaderboardSetting()).source as LX.OnlineSource return musicSdk[source]?.leaderboard.getBoards() as Promise } /** * 获取排行榜内单页歌曲 * @param id 排行榜id {souce}__{id} * @param isRefresh 是否跳过缓存 * @returns */ export const getListDetail = async(id: string, page: number, isRefresh = false): Promise => { // let [source, bangId] = tabId.split('__') // if (!bangId) return let key = `${id}__${page}` if (!isRefresh && cache.has(key)) return cache.get(key) const [source, bangId] = id.split('__') as [LX.OnlineSource, string] return musicSdk[source]?.leaderboard?.getList(bangId, page).then((result: ListDetailInfo) => { result.list = markRawList(deduplicationList(result.list.map(m => toNewMusicInfo(m)) as LX.Music.MusicInfoOnline[])) cache.set(key, result) return result }) } /** * 获取排行榜内全部歌曲 * @param id 排行榜id {souce}__{id} * @param isRefresh 是否跳过缓存 * @returns */ export const getListDetailAll = async(id: string, isRefresh = false): Promise => { const [source, bangId] = id.split('__') as [LX.OnlineSource, string] // console.log(source, id) // eslint-disable-next-line @typescript-eslint/promise-function-async const loadData = async(id: string, page: number): Promise => { let key = `${source}__${id}__${page}` if (!isRefresh && cache.has(key)) return cache.get(key) return musicSdk[source]?.leaderboard.getList(id, page).then((result: ListDetailInfo) => { result.list = markRawList(deduplicationList(result.list.map(m => toNewMusicInfo(m)) as LX.Music.MusicInfoOnline[])) cache.set(key, result) return result }) ?? Promise.reject(new Error('source not found' + source)) } // eslint-disable-next-line @typescript-eslint/promise-function-async return loadData(bangId, 1).then((result: ListDetailInfo) => { if (result.total <= result.limit) return result.list let maxPage = Math.ceil(result.total / result.limit) // eslint-disable-next-line @typescript-eslint/promise-function-async const loadDetail = (loadPage = 2): Promise => { return loadPage == maxPage ? loadData(bangId, loadPage).then((result: ListDetailInfo) => result.list) // eslint-disable-next-line @typescript-eslint/promise-function-async : loadData(bangId, loadPage).then((result1: ListDetailInfo) => loadDetail(++loadPage).then((result2: ListDetailInfo['list']) => [...result1.list, ...result2])) } return loadDetail().then(result2 => [...result.list, ...result2]) }).then((list: ListDetailInfo['list']) => deduplicationList(list)) } /** * 获取并设置排行榜内单页歌曲 * @param id 排行榜id {souce}__{id} * @param isRefresh 是否跳过缓存 * @returns */ export const getAndSetListDetail = async(id: string, page: number, isRefresh = false) => { // let [source, bangId] = tabId.split('__') // if (!bangId) return let key = `${id}__${page}` if (!isRefresh && listDetailInfo.key == key && listDetailInfo.list.length) return listDetailInfo.key = key listDetailInfo.noItemLabel = window.i18n.t('list__loading') return getListDetail(id, page, isRefresh).then((result: ListDetailInfo) => { if (key != listDetailInfo.key) return setListDetail(result, id, page) }).catch((error: any) => { clearListDetail() listDetailInfo.noItemLabel = window.i18n.t('list__load_failed') console.log(error) throw error }) } ================================================ FILE: src/renderer/store/leaderboard/state.ts ================================================ import { reactive, markRaw, shallowReactive } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' export type Source = LX.OnlineSource export const sources: LX.OnlineSource[] = markRaw([]) for (const source of music.sources) { if (!music[source.id as LX.OnlineSource]?.leaderboard?.getBoards) continue sources.push(source.id as LX.OnlineSource) } export interface BoardItem { id: string name: string bangid: string } export interface Board { list: BoardItem[] source: LX.OnlineSource } type Boards = Partial> export const boards = shallowReactive({}) export interface ListDetailInfo { list: LX.Music.MusicInfoOnline[] total: number page: number source: LX.OnlineSource | null limit: number key: string | null id: string noItemLabel: string } export const listDetailInfo = reactive({ list: [], total: 0, page: 1, limit: 30, key: null, source: null, id: '', noItemLabel: '', }) ================================================ FILE: src/renderer/store/list/action.ts ================================================ // import { } from '@renderer/utils/ipc' import { appSetting } from '@renderer/store/setting' import { fetchingListStatus, listUpdateTimes, allMusicList, userLists, tempListMeta } from './state' import { registerListAction, createUserList as createUserListAction, addListMusics as addListMusicsAction, moveListMusics as moveListMusicsAction, overwriteListMusics, } from '@renderer/store/list/listManage' import { toRaw } from '@common/utils/vueTools' import { LIST_IDS } from '@common/constants' export const registerAction = (onListChanged: (listIds: string[]) => void) => { return registerListAction(appSetting, onListChanged) } /** * 从缓存获取列表内歌曲,前提是知道列表之前已被获取过,否则返回空数组 * @param listId 列表ID * @returns */ export const getListMusicsFromCache = (listId: string | null): LX.Music.MusicInfo[] => { if (!listId) return [] if (allMusicList.has(listId)) return allMusicList.get(listId) as LX.Music.MusicInfo[] return [] } export const setFetchingListStatus = (id: string, status: boolean) => { fetchingListStatus[id] = status } export const setUpdateTime = (id: string, time: string) => { listUpdateTimes[id] = time } export const addListMusics = async(id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType?: LX.AddMusicLocationType) => { return addListMusicsAction({ id, musicInfos: toRaw(musicInfos), addMusicLocationType: addMusicLocationType ?? appSetting['list.addMusicLocationType'], }) } export const moveListMusics = async(fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType?: LX.AddMusicLocationType) => { return moveListMusicsAction({ fromId, toId, musicInfos: toRaw(musicInfos), addMusicLocationType: addMusicLocationType ?? appSetting['list.addMusicLocationType'], }) } export const createUserList = async({ name, id = `userlist_${Date.now()}`, list = [], source, sourceListId, position = -1 }: { name?: string id?: string list?: LX.Music.MusicInfo[] source?: LX.OnlineSource sourceListId?: string position?: number }) => { await createUserListAction({ position: position < 0 ? userLists.length : position, listInfos: [ { id, name: name ?? 'list', source, sourceListId, locationUpdateTime: position < 0 ? null : Date.now(), }, ], }) if (list) await addListMusics(id, list) } export const setTempList = async(id: string, list: LX.Music.MusicInfoOnline[]) => { tempListMeta.id = id await overwriteListMusics({ listId: LIST_IDS.TEMP, musicInfos: list, }) } export { addListMusicsAction, moveListMusicsAction, } export { getUserLists, removeUserList, updateUserList, updateUserListPosition, getListMusics, removeListMusics, updateListMusics, updateListMusicsPosition, overwriteListMusics, clearListMusics, overwriteListFull, checkListExistMusic, getMusicExistListIds, } from '@renderer/store/list/listManage' ================================================ FILE: src/renderer/store/list/listManage/action.ts ================================================ import { markRaw, markRawList, toRaw } from '@common/utils/vueTools' import { allMusicList, defaultList, loveList, tempList, userLists, } from './state' import { overwriteListPosition, overwriteListUpdateInfo, removeListPosition, removeListUpdateInfo } from '@renderer/utils/data' import { LIST_IDS } from '@common/constants' import { arrPush, arrUnshift } from '@common/utils/common' export const setUserLists = (lists: LX.List.UserListInfo[]) => { userLists.splice(0, userLists.length, ...lists) return userLists } export const setMusicList = (listId: string, musicList: LX.Music.MusicInfo[]) => { const list = markRawList(musicList) allMusicList.set(listId, list) return list } const overwriteMusicList = (id: string, list: LX.Music.MusicInfo[]) => { // console.log(id, list) markRawList(list) let targetList = allMusicList.get(id) if (targetList) { targetList.splice(0, targetList.length) arrPush(targetList, list) } else { allMusicList.set(id, list) } } const removeMusicList = (id: string) => { allMusicList.delete(id) } const createUserList = ({ name, id, source, sourceListId, locationUpdateTime, }: LX.List.UserListInfo, position: number) => { if (position < 0 || position >= userLists.length) { userLists.push({ name, id, source, sourceListId, locationUpdateTime, }) } else { userLists.splice(position, 0, { name, id, source, sourceListId, locationUpdateTime, }) } } const updateList = ({ name, id, source, sourceListId, meta, locationUpdateTime, }: LX.List.UserListInfo & { meta?: { id?: string } }) => { let targetList switch (id) { case defaultList.id: case loveList.id: break case tempList.id: tempList.meta = meta ?? {} break default: targetList = userLists.find(l => l.id == id) if (!targetList) return targetList.name = name targetList.source = source targetList.sourceListId = sourceListId targetList.locationUpdateTime = locationUpdateTime break } } const removeUserList = (id: string) => { const index = userLists.findIndex(l => l.id == id) if (index < 0) return userLists.splice(index, 1) // removeMusicList(id) } const overwriteUserList = (lists: LX.List.UserListInfo[]) => { userLists.splice(0, userLists.length, ...lists) } // const sendMyListUpdateEvent = (ids: string[]) => { // window.app_event.myListUpdate(ids) // } export const listDataOverwrite = ({ defaultList, loveList, userList, tempList }: MakeOptional): string[] => { const updatedListIds: string[] = [] const newUserIds: string[] = [] const newUserListInfos = userList.map(({ list, ...listInfo }) => { newUserIds.push(listInfo.id) if (allMusicList.has(listInfo.id)) { overwriteMusicList(listInfo.id, list) updatedListIds.push(listInfo.id) } return listInfo }) for (const list of userLists) { if (!allMusicList.has(list.id) || newUserIds.includes(list.id)) continue removeMusicList(list.id) updatedListIds.push(list.id) } overwriteUserList(newUserListInfos) if (allMusicList.has(LIST_IDS.DEFAULT)) { overwriteMusicList(LIST_IDS.DEFAULT, defaultList) updatedListIds.push(LIST_IDS.DEFAULT) } overwriteMusicList(LIST_IDS.LOVE, loveList) updatedListIds.push(LIST_IDS.LOVE) if (tempList && allMusicList.has(LIST_IDS.TEMP)) { overwriteMusicList(LIST_IDS.TEMP, tempList) updatedListIds.push(LIST_IDS.TEMP) } const newIds = [LIST_IDS.DEFAULT, LIST_IDS.LOVE, ...userList.map(l => l.id)] if (tempList) newIds.push(LIST_IDS.TEMP) void overwriteListPosition(newIds) void overwriteListUpdateInfo(newIds) return updatedListIds } export const userListCreate = ({ name, id, source, sourceListId, position, locationUpdateTime }: { name: string id: string source?: LX.OnlineSource sourceListId?: string position: number locationUpdateTime: number | null }) => { if (userLists.some(item => item.id == id)) return const newList: LX.List.UserListInfo = { name, id, source, sourceListId, locationUpdateTime, } createUserList(newList, position) } export const userListsRemove = (ids: string[]) => { const changedIds = [] for (const id of ids) { removeUserList(id) void removeListPosition(id) void removeListUpdateInfo(id) if (!allMusicList.has(id)) continue removeMusicList(id) changedIds.push(id) } return changedIds } export const userListsUpdate = (listInfos: LX.List.UserListInfo[]) => { for (const info of listInfos) { updateList(info) } } export const userListsUpdatePosition = (position: number, ids: string[]) => { const newUserLists = [...userLists] // console.log(position, ids) const updateLists: LX.List.UserListInfo[] = [] // const targetItem = list[position] const map = new Map() for (const item of newUserLists) map.set(item.id, item) for (const id of ids) { const listInfo = map.get(id)! listInfo.locationUpdateTime = Date.now() updateLists.push(listInfo) map.delete(id) } newUserLists.splice(0, newUserLists.length, ...newUserLists.filter(mInfo => map.has(mInfo.id))) newUserLists.splice(Math.min(position, newUserLists.length), 0, ...updateLists) setUserLists(newUserLists) } export const listMusicOverwrite = (listId: string, musicInfos: LX.Music.MusicInfo[]): string[] => { const isExist = allMusicList.has(listId) overwriteMusicList(listId, musicInfos) return isExist || listId == loveList.id ? [listId] : [] } export const listMusicClear = (ids: string[]): string[] => { const changedIds: string[] = [] for (const id of ids) { const list = allMusicList.get(id) if (!list?.length) continue overwriteMusicList(id, []) changedIds.push(id) } return changedIds } export const listMusicAdd = (id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType): string[] => { const targetList = allMusicList.get(id) if (!targetList) return id == loveList.id ? [id] : [] const listSet = new Set() for (const item of targetList) listSet.add(item.id) musicInfos = musicInfos.filter(item => { if (listSet.has(item.id)) return false markRaw(item) listSet.add(item.id) return true }) switch (addMusicLocationType) { case 'top': arrUnshift(targetList, musicInfos) break case 'bottom': default: arrPush(targetList, musicInfos) break } return [id] } export const listMusicMove = (fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType): string[] => { return [ ...listMusicRemove(fromId, musicInfos.map(musicInfo => musicInfo.id)), ...listMusicAdd(toId, musicInfos, addMusicLocationType), ] } export const listMusicRemove = (listId: string, ids: string[]): string[] => { let targetList = allMusicList.get(listId) if (!targetList) return listId == loveList.id ? [listId] : [] const idsSet = new Set(ids) const newList = targetList.filter(mInfo => !idsSet.has(mInfo.id)) targetList.splice(0, targetList.length) arrPush(targetList, newList) return [listId] } export const listMusicUpdateInfo = (musicInfos: LX.List.ListActionMusicUpdate): string[] => { const updateListIds = new Set() for (const { id, musicInfo } of musicInfos) { const targetList = allMusicList.get(id) if (!targetList) continue const index = targetList.findIndex(l => l.id == musicInfo.id) if (index < 0) continue const info: LX.Music.MusicInfo = { ...targetList[index] } Object.assign(info, { name: musicInfo.name, singer: musicInfo.singer, source: musicInfo.source, interval: musicInfo.interval, meta: musicInfo.meta, }) targetList.splice(index, 1, markRaw(info)) updateListIds.add(id) } return Array.from(updateListIds) } export const listMusicUpdatePosition = async(listId: string, position: number, ids: string[]): Promise => { let targetList = allMusicList.get(listId) if (!targetList) return listId == loveList.id ? [listId] : [] // const infos = Array(ids.length) // for (let i = targetList.length; i--;) { // const item = targetList[i] // const index = ids.indexOf(item.id) // if (index < 0) continue // infos.splice(index, 1, targetList.splice(i, 1)[0]) // } // targetList.splice(Math.min(position, targetList.length - 1), 0, ...infos) // console.time('ts') const list = await window.lx.worker.main.createSortedList(toRaw(targetList), position, ids) markRawList(list) targetList.splice(0, targetList.length) arrPush(targetList, list) // console.timeEnd('ts') return [listId] } ================================================ FILE: src/renderer/store/list/listManage/index.ts ================================================ export * from './rendererListManage' export * from './state' ================================================ FILE: src/renderer/store/list/listManage/rendererListManage.ts ================================================ import { toRaw } from '@common/utils/vueTools' import { rendererInvoke, rendererOff, rendererOn } from '@common/rendererIpc' import { PLAYER_EVENT_NAME } from '@common/ipcNames' import { userListCreate, listDataOverwrite, userListsRemove, userListsUpdate, userListsUpdatePosition, listMusicAdd, listMusicMove, listMusicRemove, listMusicOverwrite, listMusicUpdateInfo, listMusicUpdatePosition, setMusicList, setUserLists, listMusicClear, } from './action' import { allMusicList } from './state' /** * 获取用户列表 * @returns 所有用户列表 */ export const getUserLists = async() => { const lists = await rendererInvoke(PLAYER_EVENT_NAME.list_get) return setUserLists(lists) } /** * 添加用户列表 * @param data */ export const createUserList = async(data: LX.List.ListActionAdd) => { data.listInfos = data.listInfos.map(info => toRaw(info)) await rendererInvoke(PLAYER_EVENT_NAME.list_add, data) } /** * 移除用户列表及列表内歌曲 * @param data */ export const removeUserList = async(data: LX.List.ListActionRemove) => { await rendererInvoke(PLAYER_EVENT_NAME.list_remove, data) } /** * 更新用户列表 * @param data */ export const updateUserList = async(data: LX.List.ListActionUpdate) => { data = data.map(info => toRaw(info)) await rendererInvoke(PLAYER_EVENT_NAME.list_update, data) } /** * 批量移动用户列表位置 * @param data */ export const updateUserListPosition = async(data: LX.List.ListActionUpdatePosition) => { await rendererInvoke(PLAYER_EVENT_NAME.list_update_position, data) } /** * 获取列表内的歌曲 * @param listId */ export const getListMusics = async(listId: string | null): Promise => { if (!listId) return [] if (allMusicList.has(listId)) return allMusicList.get(listId)! const list = await rendererInvoke(PLAYER_EVENT_NAME.list_music_get, listId) return setMusicList(listId, list) } /** * 批量添加歌曲到列表 * @param data */ export const addListMusics = async(data: LX.List.ListActionMusicAdd) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_add, data) } /** * 跨列表批量移动歌曲 * @param data */ export const moveListMusics = async(data: LX.List.ListActionMusicMove) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_move, data) } /** * 批量删除列表内歌曲 * @param data */ export const removeListMusics = async(data: LX.List.ListActionMusicRemove) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_remove, data) } /** * 批量更新列表内歌曲 * @param data */ export const updateListMusics = async(data: LX.List.ListActionMusicUpdate) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_update, data) } /** * 批量移动列表内歌曲的位置 * @param data */ export const updateListMusicsPosition = async(data: LX.List.ListActionMusicUpdatePosition) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_update_position, data) } /** * 覆盖列表内的歌曲 * @param data */ export const overwriteListMusics = async(data: LX.List.ListActionMusicOverwrite) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_overwrite, data) } /** * 清空列表内的歌曲 * @param ids */ export const clearListMusics = async(ids: LX.List.ListActionMusicClear) => { await rendererInvoke(PLAYER_EVENT_NAME.list_music_clear, ids) } /** * 覆盖全部列表数据 * @param data */ export const overwriteListFull = async(data: LX.List.ListActionDataOverwrite) => { data.defaultList = toRaw(data.defaultList) data.loveList = toRaw(data.loveList) if (data.tempList) { data.tempList = toRaw(data.tempList) } data.userList = data.userList.map(info => { return { ...info, list: toRaw(info.list), } }) await rendererInvoke(PLAYER_EVENT_NAME.list_data_overwire, data) } /** * 检查音乐是否存在列表中 * @param listId * @param musicInfoId */ export const checkListExistMusic = async(listId: string, musicInfoId: string): Promise => { return rendererInvoke(PLAYER_EVENT_NAME.list_music_check_exist, { listId, musicInfoId }) } /** * 获取所有存在该音乐的列表id * @param musicInfoId */ export const getMusicExistListIds = async(musicInfoId: string): Promise => { return rendererInvoke(PLAYER_EVENT_NAME.list_music_get_list_ids, musicInfoId) } const noop = () => {} export const registerListAction = (appSetting: LX.AppSetting, onListChanged: (listIds: string[]) => void = noop) => { const list_data_overwrite = ({ params: datas }: LX.IpcRendererEventParams) => { const updatedListIds = listDataOverwrite(datas) if (updatedListIds.length) onListChanged(updatedListIds) } const list_create = ({ params: { position, listInfos } }: LX.IpcRendererEventParams) => { for (const list of listInfos) { userListCreate({ ...list, position }) } } const list_remove = ({ params: ids }: LX.IpcRendererEventParams) => { const updatedListIds = userListsRemove(ids) if (updatedListIds.length) onListChanged(updatedListIds) } const list_update = ({ params: listInfos }: LX.IpcRendererEventParams) => { userListsUpdate(listInfos) } const list_update_position = ({ params: { position, ids } }: LX.IpcRendererEventParams) => { userListsUpdatePosition(position, ids) } const list_music_add = ({ params: { id, musicInfos, addMusicLocationType } }: LX.IpcRendererEventParams) => { addMusicLocationType ??= appSetting['list.addMusicLocationType'] const updatedListIds = listMusicAdd(id, musicInfos, addMusicLocationType) if (updatedListIds.length) onListChanged(updatedListIds) } const list_music_move = ({ params: { fromId, toId, musicInfos, addMusicLocationType } }: LX.IpcRendererEventParams) => { addMusicLocationType ??= appSetting['list.addMusicLocationType'] const updatedListIds = listMusicMove(fromId, toId, musicInfos, addMusicLocationType) if (updatedListIds.length) onListChanged(updatedListIds) } const list_music_remove = ({ params: { listId, ids } }: LX.IpcRendererEventParams) => { // console.log(listId, ids) const updatedListIds = listMusicRemove(listId, ids) if (updatedListIds.length) onListChanged(updatedListIds) } const list_music_update = ({ params: musicInfos }: LX.IpcRendererEventParams) => { const updatedListIds = listMusicUpdateInfo(musicInfos) if (updatedListIds.length) onListChanged(updatedListIds) } const list_music_update_position = ({ params: { listId, position, ids } }: LX.IpcRendererEventParams) => { void listMusicUpdatePosition(listId, position, ids).then(updatedListIds => { if (updatedListIds.length) onListChanged(updatedListIds) }) } const list_music_overwrite = ({ params: { listId, musicInfos } }: LX.IpcRendererEventParams) => { const updatedListIds = listMusicOverwrite(listId, musicInfos) if (updatedListIds.length) onListChanged(updatedListIds) } const list_music_clear = ({ params: ids }: LX.IpcRendererEventParams) => { const updatedListIds = listMusicClear(ids) if (updatedListIds.length) onListChanged(updatedListIds) } rendererOn(PLAYER_EVENT_NAME.list_data_overwire, list_data_overwrite) rendererOn(PLAYER_EVENT_NAME.list_add, list_create) rendererOn(PLAYER_EVENT_NAME.list_remove, list_remove) rendererOn(PLAYER_EVENT_NAME.list_update, list_update) rendererOn(PLAYER_EVENT_NAME.list_update_position, list_update_position) rendererOn(PLAYER_EVENT_NAME.list_music_add, list_music_add) rendererOn(PLAYER_EVENT_NAME.list_music_move, list_music_move) rendererOn(PLAYER_EVENT_NAME.list_music_remove, list_music_remove) rendererOn(PLAYER_EVENT_NAME.list_music_update, list_music_update) rendererOn(PLAYER_EVENT_NAME.list_music_update_position, list_music_update_position) rendererOn(PLAYER_EVENT_NAME.list_music_overwrite, list_music_overwrite) rendererOn(PLAYER_EVENT_NAME.list_music_clear, list_music_clear) return () => { rendererOff(PLAYER_EVENT_NAME.list_data_overwire, list_data_overwrite) rendererOff(PLAYER_EVENT_NAME.list_add, list_create) rendererOff(PLAYER_EVENT_NAME.list_remove, list_remove) rendererOff(PLAYER_EVENT_NAME.list_update, list_update) rendererOff(PLAYER_EVENT_NAME.list_update_position, list_update_position) rendererOff(PLAYER_EVENT_NAME.list_music_add, list_music_add) rendererOff(PLAYER_EVENT_NAME.list_music_move, list_music_move) rendererOff(PLAYER_EVENT_NAME.list_music_remove, list_music_remove) rendererOff(PLAYER_EVENT_NAME.list_music_update, list_music_update) rendererOff(PLAYER_EVENT_NAME.list_music_update_position, list_music_update_position) rendererOff(PLAYER_EVENT_NAME.list_music_overwrite, list_music_overwrite) rendererOff(PLAYER_EVENT_NAME.list_music_clear, list_music_clear) } } ================================================ FILE: src/renderer/store/list/listManage/state.ts ================================================ import { LIST_IDS } from '@common/constants' import { markRaw, reactive } from '@common/utils/vueTools' export const allMusicList: Map = markRaw(new Map()) export const defaultList = markRaw({ id: LIST_IDS.DEFAULT, name: 'list__name_default', // name: '试听列表', }) export const loveList = markRaw({ id: LIST_IDS.LOVE, name: 'list__name_love', // name: '我的收藏', }) export const tempList = markRaw({ id: LIST_IDS.TEMP, name: '临时列表', meta: {}, }) export const userLists: LX.List.UserListInfo[] = reactive([]) ================================================ FILE: src/renderer/store/list/state.ts ================================================ import { reactive } from '@common/utils/vueTools' export { allMusicList, defaultList, loveList, tempList, userLists, } from '@renderer/store/list/listManage' // import { reactive, ref, markRaw, Ref } from '@common/utils/vueTools' // // const TEMP_LIST = 'TEMP_LIST' // export const isInitedList: Ref = ref(false) // export const allList: Map = window.lxData.allList = markRaw(new Map()) // export const defaultList: Omit = reactive({ // id: 'default', // name: '试听列表', // }) // export const loveList: Omit = reactive({ // id: 'love', // name: '我的收藏', // }) // export const tempList: Omit = reactive({ // id: 'temp', // name: '临时列表', // meta: {}, // }) export const tempListMeta = { id: '', } // export const userLists: LX.List.UserListInfo[] = window.lxData.userLists = reactive([]) export const fetchingListStatus = reactive>({}) export const listUpdateTimes = reactive>({}) ================================================ FILE: src/renderer/store/list/syncSourceList.ts ================================================ import { setListUpdateTime } from '@renderer/utils/data' import { setFetchingListStatus, overwriteListMusics, setUpdateTime } from './action' import { getListDetailAll } from '@renderer/store/songList/action' import { getListDetailAll as getBoardListAll } from '@renderer/store/leaderboard/action' import { dateFormat } from '@common/utils/common' const fetchList = async(id: string, source: LX.OnlineSource, sourceListId: string) => { setFetchingListStatus(id, true) let promise if (/^board__/.test(sourceListId)) { const id = sourceListId.replace(/^board__/, '') promise = id ? getBoardListAll(id, true) : Promise.reject(new Error('id not defined: ' + sourceListId)) } else { promise = getListDetailAll(sourceListId, source, true) } return promise.finally(() => { setFetchingListStatus(id, false) }) } export default async(targetListInfo: LX.List.UserListInfo) => { // console.log(targetListInfo) if (!targetListInfo.source || !targetListInfo.sourceListId) return const list = await fetchList(targetListInfo.id, targetListInfo.source, targetListInfo.sourceListId) // console.log(list) void overwriteListMusics({ listId: targetListInfo.id, musicInfos: list }) const now = Date.now() void setListUpdateTime(targetListInfo.id, now) setUpdateTime(targetListInfo.id, dateFormat(now)) } ================================================ FILE: src/renderer/store/player/action.ts ================================================ // import { reactive, ref, shallowRef } from '@common/utils/vueTools' import { type PlayerMusicInfo, musicInfo, isPlay, status, statusText, isShowPlayerDetail, isShowPlayComment, isShowLrcSelectContent, playInfo, playMusicInfo, playedList, tempPlayList, } from './state' import { getListMusicsFromCache } from '@renderer/store/list/action' import { downloadList } from '@renderer/store/download/state' import { setProgress } from './playProgress' import { playNext } from '@renderer/core/player' import { LIST_IDS } from '@common/constants' import { toRaw } from '@common/utils/vueTools' import { arrPush, arrUnshift } from '@common/utils/common' type PlayerMusicInfoKeys = keyof typeof musicInfo const musicInfoKeys: PlayerMusicInfoKeys[] = Object.keys(musicInfo) as PlayerMusicInfoKeys[] export const setMusicInfo = (_musicInfo: Partial) => { for (const key of musicInfoKeys) { const val = _musicInfo[key] if (val !== undefined) { // @ts-expect-error musicInfo[key] = val } } } export const setPlay = (val: boolean) => { isPlay.value = val } export const setStatus = (val: string) => { console.log('setStatus', val) status.value = val } export const setStatusText = (val: string) => { statusText.value = val } export const setAllStatus = (val: string) => { console.log('setAllStatus', val) status.value = val statusText.value = val } export const setShowPlayerDetail = (val: boolean) => { isShowPlayerDetail.value = val } export const setShowPlayComment = (val: boolean) => { isShowPlayComment.value = val } export const setShowPlayLrcSelectContentLrc = (val: boolean) => { isShowLrcSelectContent.value = val } export const setPlayListId = (listId: string | null) => { playInfo.playerListId = listId } export const getList = (listId: string | null): Array => { return listId == LIST_IDS.DOWNLOAD ? downloadList : getListMusicsFromCache(listId) } /** * 更新播放位置 * @returns 播放位置 */ export const updatePlayIndex = () => { const indexInfo = getPlayIndex(playMusicInfo.listId, playMusicInfo.musicInfo, playMusicInfo.isTempPlay) // console.log(indexInfo) playInfo.playIndex = indexInfo.playIndex playInfo.playerPlayIndex = indexInfo.playerPlayIndex return indexInfo } export const getPlayIndex = (listId: string | null, musicInfo: LX.Download.ListItem | LX.Music.MusicInfo | null, isTempPlay: boolean): { playIndex: number playerPlayIndex: number } => { const playerList = getList(playInfo.playerListId) // if (listIndex < 0) throw new Error('music info not found') // playInfo.playIndex = listIndex let playIndex = -1 let playerPlayIndex = -1 if (playerList.length) { playerPlayIndex = Math.min(playInfo.playerPlayIndex, playerList.length - 1) } const list = getList(listId) if (list.length && musicInfo) { const currentId = musicInfo.id playIndex = list.findIndex(m => m.id == currentId) if (!isTempPlay) { if (playIndex < 0) { playerPlayIndex = playerPlayIndex < 1 ? (list.length - 1) : (playerPlayIndex - 1) } else { playerPlayIndex = playIndex } } } return { playIndex, playerPlayIndex, } } export const resetPlayerMusicInfo = () => { setMusicInfo({ id: null, pic: null, lrc: null, tlrc: null, rlrc: null, lxlrc: null, rawlrc: null, name: '', singer: '', album: '', }) } const setPlayerMusicInfo = (musicInfo: LX.Music.MusicInfo | LX.Download.ListItem | null) => { if (musicInfo) { setMusicInfo('progress' in musicInfo ? { id: musicInfo.id, pic: musicInfo.metadata.musicInfo.meta.picUrl, name: musicInfo.metadata.musicInfo.name, singer: musicInfo.metadata.musicInfo.singer, album: musicInfo.metadata.musicInfo.meta.albumName ?? '', lrc: null, tlrc: null, rlrc: null, lxlrc: null, rawlrc: null, } : { id: musicInfo.id, pic: musicInfo.meta.picUrl, name: musicInfo.name, singer: musicInfo.singer, album: musicInfo.meta.albumName ?? '', lrc: null, tlrc: null, rlrc: null, lxlrc: null, rawlrc: null, }) } else resetPlayerMusicInfo() } /** * 设置当前播放歌曲的信息 * @param listId 歌曲所属的列表id * @param musicInfo 歌曲信息 * @param isTempPlay 是否临时播放 */ export const setPlayMusicInfo = (listId: string | null, musicInfo: LX.Download.ListItem | LX.Music.MusicInfo | null, isTempPlay: boolean = false) => { musicInfo = toRaw(musicInfo) playMusicInfo.listId = listId playMusicInfo.musicInfo = musicInfo playMusicInfo.isTempPlay = isTempPlay setPlayerMusicInfo(musicInfo) setProgress(0, 0) if (musicInfo == null) { playInfo.playIndex = -1 playInfo.playerListId = null playInfo.playerPlayIndex = -1 } else { const { playIndex, playerPlayIndex } = getPlayIndex(listId, musicInfo, isTempPlay) playInfo.playIndex = playIndex playInfo.playerPlayIndex = playerPlayIndex window.app_event.musicToggled() } } /** * 将歌曲添加到已播放列表 * @param playMusicInfo playMusicInfo对象 */ export const addPlayedList = (playMusicInfo: LX.Player.PlayMusicInfo) => { const id = playMusicInfo.musicInfo.id if (playedList.some(m => m.musicInfo.id === id)) return playedList.push(playMusicInfo) } /** * 将歌曲从已播放列表移除 * @param index 歌曲位置 */ export const removePlayedList = (index: number) => { playedList.splice(index, 1) } /** * 清空已播放列表 */ export const clearPlayedList = () => { playedList.splice(0, playedList.length) } /** * 添加歌曲到稍后播放列表 * @param list 歌曲列表 */ export const addTempPlayList = (list: LX.Player.TempPlayListItem[]) => { const topList: Array> = [] const bottomList = list.filter(({ isTop, ...musicInfo }) => { if (isTop) { topList.push(musicInfo) return false } return true }) if (topList.length) arrUnshift(tempPlayList, topList.map(({ musicInfo, listId }) => ({ musicInfo, listId, isTempPlay: true }))) if (bottomList.length) arrPush(tempPlayList, bottomList.map(({ musicInfo, listId }) => ({ musicInfo, listId, isTempPlay: true }))) if (!playMusicInfo.musicInfo) void playNext() } /** * 从稍后播放列表移除歌曲 * @param index 歌曲位置 */ export const removeTempPlayList = (index: number) => { tempPlayList.splice(index, 1) } /** * 清空稍后播放列表 */ export const clearTempPlayeList = () => { tempPlayList.splice(0, tempPlayList.length) } ================================================ FILE: src/renderer/store/player/lyric.ts ================================================ import { reactive } from '@common/utils/vueTools' export interface Line { text: string time: number extendedLyrics: string[] dom_line: HTMLDivElement } export const lyric = reactive<{ lines: Line[] text: string line: number offset: number // 歌词延迟 tempOffset: number // 歌词临时延迟 }>({ lines: [], text: '', line: 0, offset: 0, // 歌词延迟 tempOffset: 0, // 歌词临时延迟 }) export const setLines = (lines: Line[]) => { if (!lines.length && !lyric.lines.length) return lyric.lines = lines } export const setText = (text: string, line: number) => { lyric.text = text lyric.line = line } export const setOffset = (offset: number) => { lyric.offset = offset } export const setTempOffset = (offset: number) => { lyric.tempOffset = offset } ================================================ FILE: src/renderer/store/player/playProgress.ts ================================================ import { reactive } from '@common/utils/vueTools' import { formatPlayTime2 } from '@common/utils/common' export const playProgress = reactive({ nowPlayTime: 0, maxPlayTime: 0, progress: 0, nowPlayTimeStr: '00:00', maxPlayTimeStr: '00:00', }) export const setNowPlayTime = (time: number) => { playProgress.nowPlayTime = time playProgress.nowPlayTimeStr = formatPlayTime2(time) playProgress.progress = playProgress.maxPlayTime ? time / playProgress.maxPlayTime : 0 } export const setMaxplayTime = (time: number) => { playProgress.maxPlayTime = time playProgress.maxPlayTimeStr = formatPlayTime2(time) playProgress.progress = time ? playProgress.nowPlayTime / time : 0 } export const setProgress = (currentTime: number, totalTime: number) => { setMaxplayTime(totalTime) setNowPlayTime(currentTime) } ================================================ FILE: src/renderer/store/player/playbackRate.ts ================================================ import { ref } from '@common/utils/vueTools' export const playbackRate = ref(1) export const setPlaybackRate = (num: number) => { playbackRate.value = num } ================================================ FILE: src/renderer/store/player/state.ts ================================================ import { reactive, shallowReactive, ref } from '@common/utils/vueTools' export interface PlayerMusicInfo { id: string | null pic: string | null lrc: string | null tlrc: string | null rlrc: string | null lxlrc: string | null rawlrc: string | null // url: string | null name: string singer: string album: string } export const musicInfo = window.lxData.musicInfo = reactive({ id: null, pic: null, lrc: null, tlrc: null, rlrc: null, lxlrc: null, rawlrc: null, // url: null, name: '', singer: '', album: '', }) export const isPlay = ref(false) export const status = window.lxData.status = ref('') export const statusText = ref('') export const isShowPlayerDetail = ref(false) export const isShowPlayComment = ref(false) export const isShowLrcSelectContent = ref(false) export const playMusicInfo = shallowReactive<{ /** * 当前播放歌曲的列表 id */ musicInfo: LX.Player.PlayMusicInfo['musicInfo'] | null /** * 当前播放歌曲的列表 id */ listId: LX.Player.PlayMusicInfo['listId'] | null /** * 是否属于 “稍后播放” */ isTempPlay: boolean }>({ listId: null, musicInfo: null, isTempPlay: false, }) export const playInfo = shallowReactive({ playIndex: -1, playerListId: null, playerPlayIndex: -1, }) export const playedList = window.lxData.playedList = shallowReactive([]) export const tempPlayList = shallowReactive([]) window.lxData.playInfo = playInfo window.lxData.playMusicInfo = playMusicInfo ================================================ FILE: src/renderer/store/player/volume.ts ================================================ import { ref } from '@common/utils/vueTools' export const volume = ref(0) export const isMute = ref(false) export const setVolume = (num: number) => { volume.value = num } export const setMute = (flag: boolean) => { isMute.value = flag } ================================================ FILE: src/renderer/store/search/action.ts ================================================ import { throttle } from '@common/utils/common' import { toRaw } from '@common/utils/vueTools' import { getSearchHistoryList, saveSearchHistoryList, } from '@renderer/utils/ipc' import { appSetting } from '../setting' import { searchText, historyList } from './state' export const setSearchText = (text: string) => { searchText.value = text } let isInitedSearchHistory = false const saveSearchHistoryListThrottle = throttle((list: LX.List.SearchHistoryList) => { saveSearchHistoryList(list) }, 500) export const getHistoryList = async() => { if (isInitedSearchHistory || historyList.length) return historyList.push(...(await getSearchHistoryList() ?? [])) isInitedSearchHistory ||= true } export const addHistoryWord = async(word: string) => { if (!appSetting['search.isShowHistorySearch']) return if (!isInitedSearchHistory) await getHistoryList() let index = historyList.indexOf(word) if (index == 0) return if (index > -1) historyList.splice(index, 1) if (historyList.length >= 15) historyList.splice(14, historyList.length - 14) historyList.unshift(word) saveSearchHistoryListThrottle(toRaw(historyList)) } export const removeHistoryWord = (index: number) => { historyList.splice(index, 1) saveSearchHistoryListThrottle(toRaw(historyList)) } export const clearHistoryList = (id: string) => { historyList.splice(0, historyList.length) saveSearchHistoryList([]) } ================================================ FILE: src/renderer/store/search/music/action.ts ================================================ import { markRaw } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' import { deduplicationList, toNewMusicInfo } from '@renderer/utils' import { sortInsert, similar } from '@common/utils/common' import { sources, maxPages, listInfos } from './state' interface SearchResult { list: LX.Music.MusicInfo[] allPage: number limit: number total: number source: LX.OnlineSource } /** * 按搜索关键词重新排序列表 * @param list 歌曲列表 * @param keyword 搜索关键词 * @returns 排序后的列表 */ const handleSortList = (list: LX.Music.MusicInfo[], keyword: string) => { let arr: any[] = [] for (const item of list) { sortInsert(arr, { num: similar(keyword, `${item.name} ${item.singer}`), data: item, }) } return arr.map(item => item.data).reverse() } const setLists = (results: SearchResult[], page: number, text: string): LX.Music.MusicInfo[] => { let pages = [] let totals = [] let limit = 0 let list = [] for (const source of results) { maxPages[source.source] = source.allPage limit = Math.max(source.limit, limit) if (source.allPage < page) continue list.push(...source.list) pages.push(source.allPage) totals.push(source.total) } list = deduplicationList(list.map(s => markRaw(toNewMusicInfo(s)))) let listInfo = listInfos.all listInfo.maxPage = Math.max(0, ...pages) const total = Math.max(0, ...totals) if (page == 1 || (total && list.length)) listInfo.total = total else listInfo.total = limit * page // listInfo.limit = limit listInfo.page = page listInfo.list = handleSortList(list, text) if (text && !list.length && page == 1) listInfo.noItemLabel = window.i18n.t('no_item') else listInfo.noItemLabel = '' return listInfo.list } const setList = (datas: SearchResult, page: number, text: string): LX.Music.MusicInfo[] => { // console.log(datas.source, datas.list) let listInfo = listInfos[datas.source]! listInfo.list = deduplicationList(datas.list.map(s => markRaw(toNewMusicInfo(s)))) if (page == 1 || (datas.total && datas.list.length)) listInfo.total = datas.total else listInfo.total = datas.limit * page listInfo.maxPage = datas.allPage listInfo.page = page listInfo.limit = datas.limit if (text && !datas.list.length && page == 1) listInfo.noItemLabel = window.i18n.t('no_item') else listInfo.noItemLabel = '' return listInfo.list } export const resetListInfo = (sourceId: LX.OnlineSource | 'all'): [] => { let listInfo = listInfos[sourceId] if (!listInfo) return [] listInfo.list = [] listInfo.page = 0 listInfo.maxPage = 0 listInfo.total = 0 listInfo.noItemLabel = '' return [] } export const search = async(text: string, page: number, sourceId: LX.OnlineSource | 'all'): Promise => { const listInfo = listInfos[sourceId] if (!text) return resetListInfo(sourceId) const key = `${page}__${text}` if (sourceId == 'all') { listInfo!.noItemLabel = window.i18n.t('list__loading') listInfo!.key = key let task = [] for (const source of sources) { if (source == 'all') continue task.push((music[source]?.musicSearch.search(text, page, listInfos.all.limit) ?? Promise.reject(new Error('source not found: ' + source))).catch((error: any) => { console.log(error) return { allPage: 1, limit: 30, list: [], source, total: 0, } })) } return Promise.all(task).then((results: SearchResult[]) => { if (key != listInfo!.key) return [] return setLists(results, page, text) }) } else { if (listInfo?.key == key && listInfo?.list.length) return listInfo?.list listInfo!.noItemLabel = window.i18n.t('list__loading') listInfo!.key = key return music[sourceId].musicSearch.search(text, page, listInfo!.limit).then((data: SearchResult) => { if (key != listInfo!.key) return [] return setList(data, page, text) }).catch((error: any) => { resetListInfo(sourceId) listInfo!.noItemLabel = window.i18n.t('list__load_failed') console.log(error) throw error }) } } ================================================ FILE: src/renderer/store/search/music/index.ts ================================================ export * from './action' export * from './state' ================================================ FILE: src/renderer/store/search/music/state.ts ================================================ import { reactive, markRaw } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' // import { deduplicationList } from '@common/utils/renderer' export declare interface ListInfo { list: LX.Music.MusicInfo[] total: number page: number maxPage: number limit: number key: string | null noItemLabel: string } interface ListInfos extends Partial> { 'all': ListInfo } export const sources: Array = markRaw([]) export const listInfos: ListInfos = markRaw({ all: reactive({ page: 1, maxPage: 0, limit: 30, total: 0, list: [], key: null, noItemLabel: '', }), }) export const maxPages: Partial> = {} for (const source of music.sources) { if (!music[source.id as LX.OnlineSource]?.musicSearch) continue sources.push(source.id as LX.OnlineSource) listInfos[source.id as LX.OnlineSource] = reactive({ page: 1, maxPage: 0, limit: 30, total: 0, list: [], key: '', noItemLabel: '', }) maxPages[source.id as LX.OnlineSource] = 0 } sources.push('all') ================================================ FILE: src/renderer/store/search/songlist/action.ts ================================================ import { markRawList } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' import { sortInsert, similar } from '@common/utils/common' import type { ListInfoItem } from './state' import { sources, maxPages, listInfos } from './state' interface SearchResult { list: ListInfoItem[] limit: number total: number source: LX.OnlineSource } /** * 按搜索关键词重新排序列表 * @param list 歌曲列表 * @param keyword 搜索关键词 * @returns 排序后的列表 */ const handleSortList = (list: ListInfoItem[], keyword: string) => { let arr: any[] = [] for (const item of list) { sortInsert(arr, { num: similar(keyword, item.name), data: item, }) } return arr.map(item => item.data).reverse() } let maxTotals: Partial> = { } const setLists = (results: SearchResult[], page: number, text: string): ListInfoItem[] => { let totals = [] let limit = 0 let list = [] for (const source of results) { list.push(...source.list) totals.push(source.total) maxTotals[source.source] = source.total maxPages[source.source] = Math.ceil(source.total / source.limit) limit = Math.max(source.limit, limit) } markRawList(list) let listInfo = listInfos.all const total = Math.max(0, ...totals) if (page == 1 || (total && list.length)) listInfo.total = total else listInfo.total = limit * page listInfo.page = page listInfo.list = handleSortList(list, text) if (text && !list.length && page == 1) listInfo.noItemLabel = window.i18n.t('no_item') else listInfo.noItemLabel = '' return listInfo.list } const setList = (datas: SearchResult, page: number, text: string): ListInfoItem[] => { // console.log(datas.source, datas.list) let listInfo = listInfos[datas.source]! listInfo.list = markRawList(datas.list) if (page == 1 || (datas.total && datas.list.length)) listInfo.total = datas.total else listInfo.total = datas.limit * page listInfo.page = page listInfo.limit = datas.limit if (text && !datas.list.length && page == 1) listInfo.noItemLabel = window.i18n.t('no_item') else listInfo.noItemLabel = '' return listInfo.list } export const resetListInfo = (sourceId: LX.OnlineSource | 'all'): [] => { let listInfo = listInfos[sourceId] if (!listInfo) return [] listInfo.page = 1 listInfo.limit = 20 listInfo.total = 0 listInfo.list = [] listInfo.key = null listInfo.noItemLabel = '' listInfo.tagId = '' listInfo.sortId = '' return [] } export const search = async(text: string, page: number, sourceId: LX.OnlineSource | 'all'): Promise => { const listInfo = listInfos[sourceId]! if (!text) return resetListInfo(sourceId) const key = `${page}__${sourceId}__${text}` if (listInfo.key == key && listInfo.list.length) return listInfo.list if (sourceId == 'all') { listInfo.noItemLabel = window.i18n.t('list__loading') listInfo.key = key let task = [] for (const source of sources) { if (source == 'all' || (page > 1 && page > (maxPages[source]!))) continue task.push((music[source]?.songList.search(text, page, listInfos.all.limit) ?? Promise.reject(new Error('source not found: ' + source))).catch((error: any) => { console.log(error) return { list: [], total: 0, limit: listInfos.all.limit, source, } })) } return Promise.all(task).then((results: SearchResult[]) => { if (key != listInfo.key) return [] return setLists(results, page, text) }) } else { if (listInfo?.key == key && listInfo?.list.length) return listInfo?.list listInfo.noItemLabel = window.i18n.t('list__loading') listInfo.key = key return (music[sourceId]?.songList.search(text, page, listInfo.limit).then((data: SearchResult) => { if (key != listInfo.key) return [] return setList(data, page, text) }) ?? Promise.reject(new Error('source not found: ' + sourceId))).catch((error: any) => { resetListInfo(sourceId) listInfo.noItemLabel = window.i18n.t('list__load_failed') console.log(error) throw error }) } } ================================================ FILE: src/renderer/store/search/songlist/index.ts ================================================ export * from './action' export * from './state' ================================================ FILE: src/renderer/store/search/songlist/state.ts ================================================ import { reactive, markRaw } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' // import { deduplicationList } from '@common/utils/renderer' import { type ListInfo } from '@renderer/store/songList/state' export type { ListInfoItem } from '@renderer/store/songList/state' export const sources: Array = markRaw([]) export type SearchListInfo = Omit interface ListInfos extends Partial> { 'all': SearchListInfo } export const listInfos: ListInfos = markRaw({ all: reactive({ page: 1, limit: 15, total: 0, list: [], key: null, noItemLabel: '', tagId: '', sortId: '', }), }) export const maxPages: Partial> = {} for (const source of music.sources) { if (!music[source.id as LX.OnlineSource]?.songList?.search) continue sources.push(source.id as LX.OnlineSource) listInfos[source.id as LX.OnlineSource] = reactive({ page: 1, limit: 18, total: 0, list: [], key: null, noItemLabel: '', tagId: '', sortId: '', }) maxPages[source.id as LX.OnlineSource] = 0 } sources.push('all') ================================================ FILE: src/renderer/store/search/state.ts ================================================ import { ref, shallowReactive } from '@common/utils/vueTools' export const searchText = ref('') export type onlineSource = LX.OnlineSource export const historyList = shallowReactive([]) ================================================ FILE: src/renderer/store/setting.ts ================================================ import { reactive, computed } from '@common/utils/vueTools' import defaultSetting from '@common/defaultSetting' import { updateSetting as saveSetting } from '@renderer/utils/ipc' export const appSetting = window.lxData.appSetting = reactive({ ...defaultSetting }) export const isShowAnimation = computed(() => { return appSetting['common.isShowAnimation'] }) export const initSetting = (newSetting: LX.AppSetting) => { mergeSetting(newSetting) } export const mergeSetting = (newSetting: Partial) => { for (const [key, value] of Object.entries(newSetting)) { // @ts-expect-error appSetting[key] = value } } export const updateSetting = window.lxData.updateSetting = (setting: Partial) => { // console.warn(setting) void saveSetting(setting) } /** * 保存是否同意协议 * @param isAgreePact 是否同意协议 */ export const saveAgreePact = (isAgreePact: boolean) => { updateSetting({ 'common.isAgreePact': isAgreePact }) } /** * 保存音频输出id * @param id 媒体驱动id */ export const saveMediaDeviceId = (id: string) => { updateSetting({ 'player.mediaDeviceId': id }) } /** * 保存音量大小 * @param volume 音量 */ export const saveVolume = (volume: number) => { updateSetting({ 'player.volume': volume }) } /** * 设置是否静音 * @param isMute 是否静音 */ export const saveVolumeIsMute = (isMute: boolean) => { updateSetting({ 'player.isMute': isMute }) } /** * 设置播放速率 * @param rate 播放速率 */ export const savePlaybackRate = (rate: number) => { updateSetting({ 'player.playbackRate': rate }) } /** * 设置是否开启桌面歌词 * @param enabled */ export const setVisibleDesktopLyric = (enabled: boolean) => { updateSetting({ 'desktopLyric.enable': enabled }) } /** * 设置是否锁定桌面歌词 * @param isLock */ export const setLockDesktopLyric = (isLock: boolean) => { updateSetting({ 'desktopLyric.isLock': isLock }) } /** * 设置切歌模式 * @param mode */ export const setTogglePlayMode = (mode: LX.AppSetting['player.togglePlayMethod']) => { updateSetting({ 'player.togglePlayMethod': mode }) } /** * 设置API id * @param sourceId */ export const setApiSource = (sourceId: string) => { updateSetting({ 'common.apiSource': sourceId }) } /** * 设置播放详情页歌词字体大小 * @param size 字体大小 */ export const setPlayDetailLyricFont = (size: number) => { updateSetting({ 'playDetail.style.fontSize': size }) } /** * 设置播放详情页歌词对齐方式 * @param align 对齐方式 */ export const setPlayDetailLyricAlign = (align: LX.AppSetting['playDetail.style.align']) => { updateSetting({ 'playDetail.style.align': align }) } /** * 设置播放详情页音频可视化 * @param enable 是否启用 */ export const setEnableAudioVisualization = (enable: boolean) => { updateSetting({ 'player.audioVisualization': enable }) } ================================================ FILE: src/renderer/store/songList/action.ts ================================================ // import { getSongListSetting } from '@renderer/utils/data' import { deduplicationList, toNewMusicInfo } from '@renderer/utils' import musicSdk from '@renderer/utils/musicSdk' import { markRaw, markRawList } from '@common/utils/vueTools' import { tags, listInfo, listDetailInfo, selectListInfo, isVisibleListDetail, openSongListInputInfo, } from './state' import type { ListDetailInfo, ListInfoItem, ListInfo, TagInfo, } from './state' const cache = new Map() export const setTags = (tagInfo: TagInfo, source: LX.OnlineSource) => { tags[source] = markRaw(tagInfo) } export const clearList = () => { listInfo.list = [] listInfo.total = 0 listInfo.noItemLabel = '' listInfo.page = 1 listInfo.key = '' } export const setList = (result: ListInfo, tagId: string, sortId: string, page: number) => { listInfo.list = markRaw([...result.list]) if (page == 1 || (result.total && result.list.length)) listInfo.total = result.total else listInfo.total = result.limit * page listInfo.limit = result.limit listInfo.page = page listInfo.source = result.source listInfo.tagId = tagId listInfo.sortId = sortId if (result.list.length) listInfo.noItemLabel = '' else if (page == 1) listInfo.noItemLabel = window.i18n.t('no_item') } export const setListDetail = (result: ListDetailInfo, id: string, page: number) => { listDetailInfo.list = markRaw([...result.list]) listDetailInfo.id = id listDetailInfo.source = result.source if (page == 1 || (result.total && result.list.length)) listDetailInfo.total = result.total else listDetailInfo.total = result.limit * page listDetailInfo.limit = result.limit listDetailInfo.page = page listDetailInfo.info = markRaw({ ...result.info }) if (result.list.length) listDetailInfo.noItemLabel = '' else if (page == 1) listDetailInfo.noItemLabel = window.i18n.t('no_item') } export const setSelectListInfo = (info: ListInfoItem) => { selectListInfo.author = info.author selectListInfo.desc = info.desc selectListInfo.id = info.id selectListInfo.img = info.img selectListInfo.name = info.name selectListInfo.play_count = info.play_count selectListInfo.source = info.source } export const clearListDetail = () => { listDetailInfo.list = [] listDetailInfo.id = '' listDetailInfo.source = 'kw' listDetailInfo.total = 0 listDetailInfo.limit = 30 listDetailInfo.page = 1 listDetailInfo.key = null listDetailInfo.info = {} listDetailInfo.noItemLabel = '' } export const getTags = async(source: T) => { return musicSdk[source]?.songList.getTags() as Promise> } /** * 获取歌单列表 * @param source 歌单源 * @param tabId 类型id * @param sortId 排序 * @param page 页数 * @param isRefresh 是否跳过缓存 * @returns */ export const getAndSetList = async(source: LX.OnlineSource, tabId: string, sortId: string, page: number, isRefresh = false) => { // let source = rootState.setting.songList.source // let tabId = rootState.setting.songList.tagInfo.id // let sortId = rootState.setting.songList.sortId // console.log(sortId) let key = `slist__${source}__${sortId}__${tabId}__${page}` // if (state.list.list.length && state.list.key == key) return if (!isRefresh) { if (listInfo.key == key && listInfo.list.length) return if (cache.has(key)) { listInfo.key = key setList(cache.get(key), tabId, sortId, page) return } } listInfo.noItemLabel = window.i18n.t('list__loading') listInfo.key = key // clearList() return musicSdk[source]?.songList.getList(sortId, tabId, page).then((result: ListInfo) => { cache.set(key, result) if (key != listInfo.key) return setList(result, tabId, sortId, page) }).catch((error: any) => { clearList() listInfo.noItemLabel = window.i18n.t('list__load_failed') console.log(error) throw error }) } /** * 获取歌单内单页歌曲 * @param id 歌单id * @param source 歌单源 * @param isRefresh 是否跳过缓存 * @returns */ export const getListDetail = async(id: string, source: LX.OnlineSource, page: number, isRefresh = false): Promise => { let key = `sdetail__${source}__${id}__${page}` if (!isRefresh && cache.has(key)) return cache.get(key) return musicSdk[source]?.songList.getListDetail(id, page).then((result: ListDetailInfo) => { result.list = markRawList(deduplicationList(result.list.map(m => toNewMusicInfo(m)) as LX.Music.MusicInfoOnline[])) cache.set(key, result) return result }) } /** * 获取歌单内全部歌曲 * @param id 歌单id * @param source 歌单源 * @param isRefresh 是否跳过缓存 * @returns */ export const getListDetailAll = async(id: string, source: LX.OnlineSource, isRefresh = false): Promise => { // console.log(source, id) // eslint-disable-next-line @typescript-eslint/promise-function-async const loadData = (id: string, page: number): Promise => { let key = `sdetail__${source}__${id}__${page}` if (isRefresh && cache.has(key)) cache.delete(key) return cache.has(key) ? Promise.resolve(cache.get(key)) : musicSdk[source]?.songList.getListDetail(id, page).then((result: ListDetailInfo) => { result.list = markRawList(deduplicationList(result.list.map(m => toNewMusicInfo(m)) as LX.Music.MusicInfoOnline[])) cache.set(key, result) return result }) ?? Promise.reject(new Error('source not found' + source)) } // eslint-disable-next-line @typescript-eslint/promise-function-async return loadData(id, 1).then((result: ListDetailInfo) => { if (result.total <= result.limit) return result.list let maxPage = Math.ceil(result.total / result.limit) // eslint-disable-next-line @typescript-eslint/promise-function-async const loadDetail = (loadPage = 2): Promise => { return loadPage == maxPage ? loadData(id, loadPage).then((result: ListDetailInfo) => result.list) // eslint-disable-next-line @typescript-eslint/promise-function-async : loadData(id, loadPage).then((result1: ListDetailInfo) => loadDetail(++loadPage).then((result2: ListDetailInfo['list']) => [...result1.list, ...result2])) } return loadDetail().then(result2 => [...result.list, ...result2]) }).then((list: ListDetailInfo['list']) => deduplicationList(list)) } /** * 获取并设置歌单内单页歌曲 * @param id 歌单id * @param source 歌单源 * @param isRefresh 是否跳过缓存 * @returns */ export const getAndSetListDetail = async(id: string, source: LX.OnlineSource, page: number, isRefresh = false) => { let key = `sdetail__${source}__${id}__${page}` if (!isRefresh && listDetailInfo.key == key && listDetailInfo.list.length) return listDetailInfo.key = key listDetailInfo.noItemLabel = window.i18n.t('list__loading') return getListDetail(id, source, page, isRefresh).then((result: ListDetailInfo) => { if (key != listDetailInfo.key) return setListDetail(result, id, page) }).catch((error: any) => { clearListDetail() listDetailInfo.noItemLabel = window.i18n.t('list__load_failed') console.log(error) throw error }) } export const setVisibleListDetail = (visible: boolean) => { isVisibleListDetail.value = visible } export const setOpenSongListInputInfo = (text: string, source: string) => { openSongListInputInfo.text = text openSongListInputInfo.source = source } ================================================ FILE: src/renderer/store/songList/state.ts ================================================ import { reactive, markRaw, ref, shallowReactive } from '@common/utils/vueTools' import music from '@renderer/utils/musicSdk' export interface SortInfo { name: string id: string } export const sources: LX.OnlineSource[] = markRaw([]) export const sortList = markRaw>>({}) for (const source of music.sources) { const songList = music[source.id as LX.OnlineSource]?.songList if (!songList) continue sources.push(source.id as LX.OnlineSource) sortList[source.id as LX.OnlineSource] = songList.sortList as SortInfo[] } export interface TagInfoItem { parent_id: string parent_name: string id: string name: string source: T } export interface TagInfoTypeItem { name: string list: Array> } export interface TagInfo { tags: Array> hotTag: Array> source: Source } type Tags = Partial> export const tags = shallowReactive({}) export interface ListInfoItem { play_count: string id: string author: string name: string time?: string img: string // grade: basic.favorcnt / 10, desc: string | null source: LX.OnlineSource total?: string } export interface ListInfo { list: ListInfoItem[] total: number page: number limit: number key: string | null noItemLabel: string source?: LX.OnlineSource tagId: string sortId: string } export interface ListDetailInfo { list: LX.Music.MusicInfoOnline[] source: LX.OnlineSource desc: string | null total: number page: number limit: number key: string | null id: string info: { name?: string img?: string desc?: string author?: string play_count?: string } noItemLabel: string } export const listInfo = reactive({ list: [], total: 0, page: 1, limit: 30, key: null, noItemLabel: '', source: 'kw', tagId: '', sortId: '', }) export const listDetailInfo = reactive({ list: [], id: '', desc: null, total: 0, page: 1, limit: 30, key: null, source: 'kw', info: {}, noItemLabel: '', }) export const selectListInfo = markRaw({ play_count: '', id: '', author: '', name: '', time: '', img: '', // grade: basic.favorcnt / 10, desc: '', source: 'kw', }) export const isVisibleListDetail = ref(false) export const openSongListInputInfo = markRaw({ text: '', source: '', }) ================================================ FILE: src/renderer/store/soundEffect.ts ================================================ import { reactive, toRaw } from '@common/utils/vueTools' import { getUserSoundEffectConvolutionPresetList, getUserSoundEffectEQPresetList, // getUserSoundEffectPitchShifterPresetList, saveUserSoundEffectConvolutionPresetList, saveUserSoundEffectEQPresetList, // saveUserSoundEffectPitchShifterPresetList, } from '@renderer/utils/ipc' let userEqPresetList: LX.SoundEffect.EQPreset[] | null = null export const getUserEQPresetList = async() => { if (userEqPresetList == null) { // eslint-disable-next-line require-atomic-updates userEqPresetList = reactive(await getUserSoundEffectEQPresetList()) } return userEqPresetList } export const saveUserEQPreset = async(preset: LX.SoundEffect.EQPreset) => { if (userEqPresetList == null) { // eslint-disable-next-line require-atomic-updates userEqPresetList = reactive(await getUserSoundEffectEQPresetList()) } const target = userEqPresetList.find(p => p.id == preset.id) if (target) Object.assign(target, preset) else userEqPresetList.push(preset) saveUserSoundEffectEQPresetList(toRaw(userEqPresetList)) } export const removeUserEQPreset = async(id: string) => { if (userEqPresetList == null) { // eslint-disable-next-line require-atomic-updates userEqPresetList = reactive(await getUserSoundEffectEQPresetList()) } const index = userEqPresetList.findIndex(p => p.id == id) if (index < 0) return userEqPresetList.splice(index, 1) saveUserSoundEffectEQPresetList(toRaw(userEqPresetList)) } let userConvolutionPresetList: LX.SoundEffect.ConvolutionPreset[] | null = null export const getUserConvolutionPresetList = async() => { if (userConvolutionPresetList == null) { // eslint-disable-next-line require-atomic-updates userConvolutionPresetList = reactive(await getUserSoundEffectConvolutionPresetList()) } return userConvolutionPresetList } export const saveUserConvolutionPreset = async(preset: LX.SoundEffect.ConvolutionPreset) => { if (userConvolutionPresetList == null) { // eslint-disable-next-line require-atomic-updates userConvolutionPresetList = reactive(await getUserSoundEffectConvolutionPresetList()) } const target = userConvolutionPresetList.find(p => p.id == preset.id) if (target) Object.assign(target, preset) else userConvolutionPresetList.push(preset) saveUserSoundEffectConvolutionPresetList(toRaw(userConvolutionPresetList)) } export const removeUserConvolutionPreset = async(id: string) => { if (userConvolutionPresetList == null) { // eslint-disable-next-line require-atomic-updates userConvolutionPresetList = reactive(await getUserSoundEffectConvolutionPresetList()) } const index = userConvolutionPresetList.findIndex(p => p.id == id) if (index < 0) return userConvolutionPresetList.splice(index, 1) saveUserSoundEffectConvolutionPresetList(toRaw(userConvolutionPresetList)) } // let userPitchShifterPresetList: LX.SoundEffect.PitchShifterPreset[] | null = null // export const getUserPitchShifterPresetList = async() => { // if (userEqPresetList == null) { // userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList()) // } // return userPitchShifterPresetList // } // export const saveUserPitchShifterPreset = async(preset: LX.SoundEffect.PitchShifterPreset) => { // if (userPitchShifterPresetList == null) { // userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList()) // } // const target = userPitchShifterPresetList.find(p => p.id == preset.id) // if (target) Object.assign(target, preset) // else userPitchShifterPresetList.push(preset) // saveUserSoundEffectPitchShifterPresetList(toRaw(userPitchShifterPresetList)) // } // export const removeUserPitchShifterPreset = async(id: string) => { // if (userPitchShifterPresetList == null) { // userPitchShifterPresetList = reactive(await getUserSoundEffectPitchShifterPresetList()) // } // const index = userPitchShifterPresetList.findIndex(p => p.id == id) // if (index < 0) return // userPitchShifterPresetList.splice(index, 1) // saveUserSoundEffectPitchShifterPresetList(toRaw(userPitchShifterPresetList)) // } ================================================ FILE: src/renderer/store/utils.ts ================================================ // import { getListFromState } from './list' // import { downloadList } from './download' // export const getList = (listId: string | null): LX.Download.ListItem[] | LX.Music.MusicInfo[] => { // return listId == 'download' ? downloadList : getListFromState(listId) // } import { encodePath, isUrl } from '@common/utils/common' import { joinPath } from '@common/utils/nodejs' import { markRaw, shallowReactive } from '@common/utils/vueTools' import { getThemes as getTheme } from '@renderer/utils/ipc' import { qualityList, themeInfo, themeShouldUseDarkColors } from './index' export const assertApiSupport = (source: LX.Source): boolean => { return source == 'local' || qualityList.value[source] != null } export const buildBgUrl = (originUrl: string, dataPath: string): string => { return isUrl(originUrl) ? `url(${originUrl})` : `url(file:///${encodePath(joinPath(dataPath, originUrl).replaceAll('\\', '/'))})` } export const getThemes = (callback: (themeInfo: LX.ThemeInfo) => void) => { if (themeInfo.themes.length) { callback(themeInfo) return } void getTheme().then(info => { themeInfo.themes = markRaw(info.themes) themeInfo.userThemes = shallowReactive(info.userThemes) themeInfo.dataPath = info.dataPath callback(themeInfo) }) } export const buildThemeColors = (theme: LX.Theme, dataPath: string) => { if (theme.isCustom && theme.config.extInfo['--background-image'] != 'none') { theme = copyTheme(theme) theme.config.extInfo['--background-image'] = buildBgUrl(theme.config.extInfo['--background-image'], dataPath) } const colors: Record = { ...theme.config.themeColors, ...theme.config.extInfo, } return colors } export const copyTheme = (theme: LX.Theme): LX.Theme => { return { ...theme, config: { ...theme.config, extInfo: { ...theme.config.extInfo }, themeColors: { ...theme.config.themeColors }, }, } } export const findTheme = (themeInfo: LX.ThemeInfo, id: string): LX.Theme | undefined => { let theme = themeInfo.themes.find(theme => theme.id == id) if (theme) return theme theme = themeInfo.userThemes.find(theme => theme.id == id) return theme } export const applyTheme = (id: string, lightId: string, darkId: string, dataPath: string) => { getThemes((themeInfo) => { let themeId = id == 'auto' ? themeShouldUseDarkColors.value ? darkId : lightId : id let theme = findTheme(themeInfo, themeId) if (!theme) { themeId = id == 'auto' && themeShouldUseDarkColors.value ? 'black' : 'green' theme = themeInfo.themes.find(theme => theme.id == themeId)! } window.setTheme(buildThemeColors(theme, dataPath)) }) } ================================================ FILE: src/renderer/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "isolatedModules": true, "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ "@common/*": ["common/*"], "@renderer/*": ["renderer/*"], // "@lyric/*": ["renderer-lyric/*"], "@static/*": ["static/*"], "@root/*": ["./*"], }, "typeRoots": [ /* Specify multiple folders that act like './node_modules/@types'. */ "./types" ], }, "vueCompilerOptions": { "plugins": [ "@vue/language-plugin-pug" ] } // "include": [ // "./**/*.ts", // // "./**/*.js", // "./**/*.vue", // "./**/*.json", // ], } ================================================ FILE: src/renderer/types/app.d.ts ================================================ /* eslint-disable no-var */ import { type AppEventTypes, type KeyEventTypes } from '@renderer/event' import { type MainTypes, type DownloadTypes } from '@renderer/worker/utils' import { type I18n } from '@renderer/plugins/i18n' // interface LX.EnvParams { // deeplink?: string | null // cmdParams: LX.CmdParams // workAreaSize?: Electron.Size // } interface Lx { // appSetting: LX.AppSetting isEditingHotKey: boolean isPlayedStop: boolean appHotKeyConfig: LX.HotKeyConfigAll restorePlayInfo: LX.Player.SavedPlayInfo | null worker: { main: MainTypes download: DownloadTypes } isProd: boolean songListInfo: { fromName: string searchKey: string | null searchPosition?: number songlistKey: string | null songlistPosition?: number } rootOffset: number apiInitPromise: [Promise, boolean, (success: boolean) => void] } declare global { interface Window { ELECTRON_DISABLE_SECURITY_WARNINGS?: string dt: boolean shouldUseDarkColors: boolean lx: Lx app_event: AppEventTypes key_event: KeyEventTypes i18n: I18n lxData: any setTheme: (colors: Record) => void setLang: (lang?: string) => void } module NodeJS { interface ProcessVersions { app: string } } // const ENVIRONMENT: NodeJS.ProcessEnv namespace LX { interface KeyDownEevent { /** * 原始事件 */ event: KeyEvent | null /** * 按下的按键数组 */ keys: string[] /** * 按下的按键组合 * * 类似:`shift`、`mod+a` * * 其中 `Ctrl` 的名称为 `mod`, 对应 MacOS 上的 `Command` 键 */ key: string /** * 当前触发此事件的单个按键(不包括之前已按下的键) */ eventKey: string /** * 按键操作类型 */ type: 'down' | 'up' } type LyricFormat = 'gbk' | 'utf8' class KeyEvent extends KeyboardEvent { /** * 此事件是否标记为 已被处理,如果设置为`true`,则停止触发key event事件 */ lx_handled?: boolean } } var COMMIT_ID: string var COMMIT_DATE: string } // declare const ELECTRON_DISABLE_SECURITY_WARNINGS: string // declare const userApiPath: string ================================================ FILE: src/renderer/types/common.d.ts ================================================ import '@common/types/app_setting' import '@common/types/common' import '@common/types/user_api' import '@common/types/sync' import '@common/types/list_sync' import '@common/types/music' import '@common/types/list' import '@common/types/download_list' import '@common/types/player' import '@common/types/shims_vue' import '@common/types/utils' import '@common/types/theme' import '@common/types/desktop_lyric' import '@common/types/ipc_renderer' import '@common/types/config_files' import '@common/types/music_metadata' import '@common/types/sound_effect' import '@common/types/dislike_list' import '@common/types/open_api' ================================================ FILE: src/renderer/types/i18n.d.ts ================================================ import { type I18n } from '@root/lang' declare module 'vue' { interface ComponentCustomProperties { $t: I18n['t'] } } ================================================ FILE: src/renderer/types/player.d.ts ================================================ declare namespace LX { namespace Player { interface PlayMusicInfo { /** * 当前播放歌曲的列表 id */ musicInfo: LX.Download.ListItem | LX.Music.MusicInfo /** * 当前播放歌曲的列表 id */ listId: string | null /** * 是否属于 “稍后播放” */ isTempPlay: boolean } interface PlayInfo { /** * 当前正在播放歌曲 index */ playIndex: number /** * 播放器的播放列表 id */ playerListId: string | null /** * 播放器播放歌曲 index */ playerPlayIndex: number } interface TempPlayListItem { /** * 播放列表id */ listId: string | null /** * 歌曲信息 */ musicInfo: LX.Music.MusicInfo | LX.Download.ListItem /** * 是否添加到列表顶部 */ isTop?: boolean } interface SavedPlayInfo { time: number maxTime: number listId: string index: number } } } ================================================ FILE: src/renderer/types/worker.d.ts ================================================ import { type workerMainTypes } from '@renderer/worker/main/index' import { type workerDownloadTypes } from '@renderer/worker/download/index' declare global { namespace LX { type WorkerMainTypes = workerMainTypes type WorkerDownloadTypes = workerDownloadTypes } } ================================================ FILE: src/renderer/utils/compositions/useDrag.js ================================================ import Sortable, { AutoScroll } from 'sortablejs/modular/sortable.core.esm' import { onMounted } from '@common/utils/vueTools' import { clearDownKeys } from '@renderer/event' Sortable.mount(new AutoScroll()) const noop = () => {} export default ({ dom_list, dragingItemClassName, filter, onUpdate, onStart = noop, onEnd = noop }) => { let sortable onMounted(() => { sortable = Sortable.create(dom_list.value, { animation: 150, disabled: true, forceFallback: false, filter: filter ? '.' + filter : null, ghostClass: dragingItemClassName, onUpdate(event) { onUpdate(event.newIndex, event.oldIndex) }, onMove(event) { return filter ? !event.related.classList.contains(filter) : true }, onChoose() { onStart() }, onUnchoose() { onEnd() // 处于拖动状态期间,键盘事件无法监听,拖动结束手动清理按下的键 // window.app_event.emit(eventBaseName.setClearDownKeys) clearDownKeys() }, onStart(event) { window.app_event.dragStart() }, onEnd(event) { window.app_event.dragEnd() }, }) }) return { setDisabled(enable) { if (!sortable) return sortable.option('disabled', enable) }, } } ================================================ FILE: src/renderer/utils/compositions/useIconSize.ts ================================================ import { type Ref, onBeforeUnmount, onMounted, ref } from '@common/utils/vueTools' const onDomSizeChanged = (dom: HTMLElement, onChanged: (width: number, height: number) => void) => { // 使用 ResizeObserver 监听大小变化 const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { const { width, height } = entry.contentRect // console.log(dom.offsetLeft, dom.offsetTop, left, top, width, height) onChanged(Math.trunc(width), Math.trunc(height)) } }) resizeObserver.observe(dom) onChanged(dom.clientWidth, dom.clientHeight) return () => { resizeObserver.disconnect() } } export const useIconSize = (parentDom: Ref, size: number) => { const iconSize = ref('32px') let unsub: (() => void) | null = null onMounted(() => { if (!parentDom.value) return unsub = onDomSizeChanged(parentDom.value, (width, height) => { iconSize.value = Math.trunc(width * size) + 'px' }) }) onBeforeUnmount(() => { unsub?.() }) return iconSize } ================================================ FILE: src/renderer/utils/compositions/useImportTip.js ================================================ import { useI18n } from '@renderer/plugins/i18n' import { dialog } from '@renderer/plugins/Dialog' export default () => { const t = useI18n() return (type) => { let message switch (type) { case 'defautlList': case 'playList': case 'playList_v2': message = t('list_import_tip__playlist') break case 'setting': case 'setting_v2': message = t('list_import_tip__setting') break case 'allData': case 'allData_v2': message = t('list_import_tip__alldata') break case 'playListPart': case 'playListPart_v2': message = t('list_import_tip__playlist_part') break default: message = t('list_import_tip__unknown') break } dialog({ message, confirmButtonText: t('ok'), }) } } ================================================ FILE: src/renderer/utils/compositions/useKeyDown.ts ================================================ import { onMounted, onBeforeUnmount, ref } from '@common/utils/vueTools' export default (name: string) => { const keyDown = ref(false) const down = `key_${name}_down` const up = `key_${name}_up` const handle_key_down = (event: LX.KeyDownEevent) => { if (!keyDown.value) { // console.log(event) switch ((event.event?.target as HTMLElement).tagName) { case 'INPUT': case 'SELECT': case 'TEXTAREA': return default: if ((event.event?.target as HTMLElement).isContentEditable) return } keyDown.value = true } } const handle_key_up = () => { keyDown.value &&= false } onMounted(() => { window.key_event.on(down, handle_key_down) window.key_event.on(up, handle_key_up) }) onBeforeUnmount(() => { window.key_event.off(down, handle_key_down) window.key_event.off(up, handle_key_up) }) return keyDown } ================================================ FILE: src/renderer/utils/compositions/useLyric.js ================================================ import { ref, onMounted, onBeforeUnmount, watch, nextTick } from '@common/utils/vueTools' import { throttle, formatPlayTime2 } from '@common/utils/common' import { scrollTo } from '@common/utils/renderer' import { play } from '@renderer/core/player/action' import { appSetting } from '@renderer/store/setting' // import { player as eventPlayerNames } from '@renderer/event/names' export default ({ isPlay, lyric, playProgress, isShowLyricProgressSetting, offset }) => { const dom_lyric = ref(null) const dom_lyric_text = ref(null) const dom_skip_line = ref(null) const isMsDown = ref(false) const isStopScroll = ref(false) const timeStr = ref('--/--') let msDownY = 0 let msDownScrollY = 0 let timeout = null let cancelScrollFn let dom_lines let isSetedLines = false let point = { x: null, y: null, } let time = -1 let dom_pre_line = null let isSkipMouseEnter = false const handleSkipPlay = () => { if (time == -1) return handleSkipMouseLeave() isStopScroll.value = false window.app_event.setProgress(time) if (!isPlay.value) play() } const handleSkipMouseEnter = () => { isSkipMouseEnter = true clearLyricScrollTimeout() } const handleSkipMouseLeave = () => { isSkipMouseEnter = false startLyricScrollTimeout() } const throttleSetTime = throttle(() => { if (!dom_skip_line.value) return const rect = dom_skip_line.value.getBoundingClientRect() point.x = rect.x point.y = rect.y let dom = document.elementFromPoint(point.x, point.y) if (dom_pre_line === dom) return if (dom.tagName == 'SPAN') { dom = dom.parentNode.parentNode } else if (dom.classList.contains('line')) { dom = dom.parentNode } if (dom.time == null) { if (lyric.lines.length) { time = dom.classList.contains('pre') ? 0 : lyric.lines[lyric.lines.length - 1].time ?? 0 time = Math.max(time - lyric.offset - lyric.tempOffset, 0) time /= 1000 if (time > playProgress.maxPlayTime) time = playProgress.maxPlayTime timeStr.value = formatPlayTime2(time) } else { time = -1 timeStr.value = '--:--' } } else { time = dom.time time = Math.max(time - lyric.offset - lyric.tempOffset, 0) time /= 1000 if (time > playProgress.maxPlayTime) time = playProgress.maxPlayTime timeStr.value = formatPlayTime2(time) } dom_pre_line = dom }) const setTime = () => { if (isShowLyricProgressSetting.value) throttleSetTime() } const handleScrollLrc = (duration = 300) => { if (!dom_lines?.length || !dom_lyric.value) return if (isSkipMouseEnter) return if (isStopScroll.value) return let dom_p = dom_lines[lyric.line] cancelScrollFn = scrollTo(dom_lyric.value, dom_p ? (dom_p.offsetTop - dom_lyric.value.clientHeight * 0.38) : 0, duration) } const clearLyricScrollTimeout = () => { if (!timeout) return clearTimeout(timeout) timeout = null } const startLyricScrollTimeout = () => { clearLyricScrollTimeout() if (isSkipMouseEnter) return timeout = setTimeout(() => { timeout = null isStopScroll.value = false if (!isPlay.value) return handleScrollLrc() }, 3000) } const handleLyricDown = (y) => { // console.log(event) if (delayScrollTimeout) { clearTimeout(delayScrollTimeout) delayScrollTimeout = null } isMsDown.value = true msDownY = y msDownScrollY = dom_lyric.value.scrollTop } const handleLyricMouseDown = event => { handleLyricDown(event.clientY) } const handleLyricTouchStart = event => { if (event.changedTouches.length) { const touch = event.changedTouches[0] handleLyricDown(touch.clientY) } } const handleMouseMsUp = event => { isMsDown.value = false } const handleMove = (y) => { if (isMsDown.value) { isStopScroll.value ||= true if (cancelScrollFn) { cancelScrollFn() cancelScrollFn = null } dom_lyric.value.scrollTop = msDownScrollY + msDownY - y startLyricScrollTimeout() setTime() } } const handleMouseMsMove = event => { handleMove(event.clientY) } const handleTouchMove = (e) => { if (e.changedTouches.length) { const touch = e.changedTouches[0] handleMove(touch.clientY) } } const handleWheel = (event) => { console.log(event.deltaY) isStopScroll.value ||= true if (cancelScrollFn) { cancelScrollFn() cancelScrollFn = null } dom_lyric.value.scrollTop = dom_lyric.value.scrollTop + event.deltaY startLyricScrollTimeout() setTime() } const setLyric = (lines) => { const dom_line_content = document.createDocumentFragment() for (const line of lines) { dom_line_content.appendChild(line.dom_line) } dom_lyric_text.value.textContent = '' dom_lyric_text.value.appendChild(dom_line_content) nextTick(() => { dom_lines = dom_lyric.value.querySelectorAll('.line-content') handleScrollLrc() }) } const initLrc = (lines, oLines) => { isSetedLines = true if (oLines) { if (lines.length) { setLyric(lines) } else { cancelScrollFn = scrollTo(dom_lyric.value, 0, 300, () => { if (lyric.lines !== lines) return setLyric(lines) }, 50) } } else { setLyric(lines) } } let delayScrollTimeout const scrollLine = (line, oldLine) => { if (line < 0) return if (line == 0 && isSetedLines) return isSetedLines = false isSetedLines &&= false if (oldLine == null || line - oldLine != 1) return handleScrollLrc() if (appSetting['playDetail.isDelayScroll']) { delayScrollTimeout = setTimeout(() => { delayScrollTimeout = null handleScrollLrc(600) }, 600) } else { handleScrollLrc() } } watch(() => lyric.lines, initLrc) watch(() => lyric.line, scrollLine) onMounted(() => { document.addEventListener('mousemove', handleMouseMsMove) document.addEventListener('mouseup', handleMouseMsUp) document.addEventListener('touchmove', handleTouchMove) document.addEventListener('touchend', handleMouseMsUp) initLrc(lyric.lines, null) }) onBeforeUnmount(() => { document.removeEventListener('mousemove', handleMouseMsMove) document.removeEventListener('mouseup', handleMouseMsUp) document.removeEventListener('touchmove', handleTouchMove) document.removeEventListener('touchend', handleMouseMsUp) }) return { dom_lyric, dom_lyric_text, dom_skip_line, isStopScroll, isMsDown, timeStr, handleLyricMouseDown, handleLyricTouchStart, handleWheel, handleSkipPlay, handleSkipMouseEnter, handleSkipMouseLeave, handleScrollLrc, } } ================================================ FILE: src/renderer/utils/compositions/useMenuLocation.js ================================================ import { onMounted, onBeforeUnmount, watch, reactive, ref } from '@common/utils/vueTools' export default ({ visible, location, onHide }) => { const transition1 = 'transform, opacity' const transition2 = 'transform, opacity, top, left' let show = false const dom_menu = ref(null) const menuStyles = reactive({ left: 0, top: 0, opacity: 0, transitionProperty: 'transform, opacity', transform: 'scale(.8, .7) translate(0,0)', pointerEvents: 'none', }) const handleShow = () => { show = true menuStyles.opacity = 1 menuStyles.transform = `scale(1) translate(${handleGetOffsetXY(location.value.x, location.value.y)})` menuStyles.pointerEvents = 'auto' } const handleHide = () => { menuStyles.opacity = 0 menuStyles.transform = 'scale(.8, .7) translate(0, 0)' menuStyles.pointerEvents = 'none' show = false } const handleGetOffsetXY = (left, top) => { const listWidth = dom_menu.value.clientWidth const listHeight = dom_menu.value.clientHeight const dom_container_parant = dom_menu.value.offsetParent const containerWidth = dom_container_parant.clientWidth const containerHeight = dom_container_parant.clientHeight const offsetWidth = containerWidth - left - listWidth const offsetHeight = containerHeight - top - listHeight let x = 0 let y = 0 if (containerWidth > listWidth && offsetWidth < 12) { x = offsetWidth - 12 } if (containerHeight > listHeight && offsetHeight < 5) { y = offsetHeight - 5 } return `${x}px, ${y}px` } const handleDocumentClick = (event) => { if (!show) return if (event.target == dom_menu.value || dom_menu.value.contains(event.target)) return if (show && menuStyles.transitionProperty != transition1) menuStyles.transitionProperty = transition1 onHide() } watch(visible, visible => { visible ? handleShow() : handleHide() }, { immediate: true }) watch(location, location => { menuStyles.left = location.x - window.lx.rootOffset + 2 + 'px' menuStyles.top = location.y - window.lx.rootOffset + 'px' // nextTick(() => { if (show) { if (menuStyles.transitionProperty != transition2) menuStyles.transitionProperty = transition2 menuStyles.transform = `scale(1) translate(${handleGetOffsetXY(location.x, location.y)})` } // }) }, { deep: true }) onMounted(() => { document.addEventListener('click', handleDocumentClick) }) onBeforeUnmount(() => { document.removeEventListener('click', handleDocumentClick) }) return { dom_menu, menuStyles, } } ================================================ FILE: src/renderer/utils/compositions/useNextTogglePlay.ts ================================================ import { appSetting, setTogglePlayMode } from '@renderer/store/setting' import { computed, } from '@common/utils/vueTools' import { useI18n } from '@renderer/plugins/i18n' // const playNextModes = [ // 'listLoop', // 'random', // 'list', // 'singleLoop', // 'none', // ] as const export default () => { const t = useI18n() const nextTogglePlayName = computed(() => { switch (appSetting['player.togglePlayMethod']) { case 'listLoop': return t('player__play_toggle_mode_list_loop') case 'random': return t('player__play_toggle_mode_random') case 'singleLoop': return t('player__play_toggle_mode_single_loop') case 'list': return t('player__play_toggle_mode_list') default: return t('player__play_toggle_mode_off') } }) const toggleNextPlayMode = (mode: LX.AppSetting['player.togglePlayMethod']) => { if (mode == appSetting['player.togglePlayMethod']) return // let index = playNextModes.indexOf(appSetting['player.togglePlayMethod']) // if (++index >= playNextModes.length) index = 0 setTogglePlayMode(mode) } return { nextTogglePlayName, toggleNextPlayMode, } } ================================================ FILE: src/renderer/utils/compositions/usePlayProgress.js ================================================ import { ref, onBeforeUnmount, toRef } from '@common/utils/vueTools' import { playProgress } from '@renderer/store/player/playProgress' export default () => { const isActiveTransition = ref(false) const progress = toRef(playProgress, 'progress') const nowPlayTimeStr = toRef(playProgress, 'nowPlayTimeStr') const maxPlayTimeStr = toRef(playProgress, 'maxPlayTimeStr') const handleTransitionEnd = () => { isActiveTransition.value = false } const handleActiveTransition = () => { isActiveTransition.value = true } window.app_event.on('activePlayProgressTransition', handleActiveTransition) onBeforeUnmount(() => { window.app_event.off('activePlayProgressTransition', handleActiveTransition) }) return { nowPlayTimeStr, maxPlayTimeStr, progress, isActiveTransition, handleTransitionEnd, } } ================================================ FILE: src/renderer/utils/compositions/useToggleDesktopLyric.js ================================================ import { computed, } from '@common/utils/vueTools' import { useI18n } from '@renderer/plugins/i18n' import { appSetting, setLockDesktopLyric, setVisibleDesktopLyric } from '@renderer/store/setting' export default () => { const t = useI18n() const toggleDesktopLyricBtnTitle = computed(() => { return `${ appSetting['desktopLyric.enable'] ? t('player__desktop_lyric_off') : t('player__desktop_lyric_on') }\n(${ appSetting['desktopLyric.isLock'] ? t('player__desktop_lyric_unlock') : t('player__desktop_lyric_lock') })` }) const toggleDesktopLyric = () => { setVisibleDesktopLyric(!appSetting['desktopLyric.enable']) } const toggleLockDesktopLyric = () => { setLockDesktopLyric(!appSetting['desktopLyric.isLock']) } return { toggleDesktopLyricBtnTitle, toggleDesktopLyric, toggleLockDesktopLyric, } } ================================================ FILE: src/renderer/utils/data.ts ================================================ /* eslint-disable @typescript-eslint/no-dynamic-delete */ import { saveListPositionInfo as saveListPositionInfoFromData, getListPositionInfo as getListPositionInfoFromData, saveListPrevSelectId as saveListPrevSelectIdFromData, getListPrevSelectId as getListPrevSelectIdFromData, saveListUpdateInfo as saveListUpdateInfoFromData, getListUpdateInfo as getListUpdateInfoFromData, saveSearchSetting as saveSearchSettingFromData, getSearchSetting as getSearchSettingFromData, saveSongListSetting as saveSongListSettingFromData, getSongListSetting as getSongListSettingFromData, saveLeaderboardSetting as saveLeaderboardSettingFromData, getLeaderboardSetting as getLeaderboardSettingFromData, saveViewPrevState as saveViewPrevStateFromData, } from '@renderer/utils/ipc' import { throttle } from '@common/utils' import { type DEFAULT_SETTING, LIST_IDS } from '@common/constants' import { dateFormat } from './index' import { setUpdateTime } from '@renderer/store/list/action' let listPosition: LX.List.ListPositionInfo let listPrevSelectId: string let listUpdateInfo: LX.List.ListUpdateInfo let searchSetting: typeof DEFAULT_SETTING['search'] let songListSetting: typeof DEFAULT_SETTING['songList'] let leaderboardSetting: typeof DEFAULT_SETTING['leaderboard'] const saveListPositionThrottle = throttle(() => { saveListPositionInfoFromData(listPosition) }, 1000) const saveSearchSettingThrottle = throttle(() => { saveSearchSettingFromData(searchSetting) }, 1000) const saveSongListSettingThrottle = throttle(() => { saveSongListSettingFromData(songListSetting) }, 1000) const saveLeaderboardSettingThrottle = throttle(() => { saveLeaderboardSettingFromData(leaderboardSetting) }, 1000) const saveViewPrevStateThrottle = throttle((state) => { saveViewPrevStateFromData(state) }, 1000) const initPosition = async() => { // eslint-disable-next-line require-atomic-updates listPosition ??= await getListPositionInfoFromData() ?? {} } export const getListPosition = async(id: string): Promise => { await initPosition() return listPosition[id] ?? 0 } export const setListPosition = async(id: string, position?: number) => { await initPosition() listPosition[id] = position ?? 0 saveListPositionThrottle() } export const removeListPosition = async(id: string) => { await initPosition() if (listPosition[id] == null) return delete listPosition[id] saveListPositionThrottle() } export const overwriteListPosition = async(ids: string[]) => { await initPosition() const removedIds = [] for (const id of Object.keys(listPosition)) { if (ids.includes(id)) continue removedIds.push(id) } for (const id of removedIds) delete listPosition[id] saveListPositionThrottle() } const saveListPrevSelectIdThrottle = throttle(() => { saveListPrevSelectIdFromData(listPrevSelectId) }, 200) export const getListPrevSelectId = async() => { // eslint-disable-next-line require-atomic-updates listPrevSelectId ??= await getListPrevSelectIdFromData() ?? LIST_IDS.DEFAULT return listPrevSelectId ?? LIST_IDS.DEFAULT } export const saveListPrevSelectId = (id: string) => { listPrevSelectId = id saveListPrevSelectIdThrottle() } const saveListUpdateInfo = throttle(() => { saveListUpdateInfoFromData(listUpdateInfo) }, 1000) const initListUpdateInfo = async() => { if (listUpdateInfo == null) { // eslint-disable-next-line require-atomic-updates listUpdateInfo = await getListUpdateInfoFromData() ?? {} for (const [id, info] of Object.entries(listUpdateInfo)) { setUpdateTime(id, info.updateTime ? dateFormat(info.updateTime) : '') } } } export const getListUpdateInfo = async() => { await initListUpdateInfo() return listUpdateInfo } export const setListUpdateInfo = async(info: LX.List.ListUpdateInfo) => { await initListUpdateInfo() listUpdateInfo = info saveListUpdateInfo() } export const setListAutoUpdate = async(id: string, enable: boolean) => { await initListUpdateInfo() const targetInfo = listUpdateInfo[id] ?? { updateTime: 0, isAutoUpdate: false } targetInfo.isAutoUpdate = enable listUpdateInfo[id] = targetInfo saveListUpdateInfo() } export const setListUpdateTime = async(id: string, time: number) => { await initListUpdateInfo() const targetInfo = listUpdateInfo[id] ?? { updateTime: 0, isAutoUpdate: false } targetInfo.updateTime = time listUpdateInfo[id] = targetInfo saveListUpdateInfo() } // export const setListUpdateInfo = (id, { updateTime, isAutoUpdate }) => { // listUpdateInfo[id] = { updateTime, isAutoUpdate } // saveListUpdateInfo() // } export const removeListUpdateInfo = async(id: string) => { await initListUpdateInfo() if (listUpdateInfo[id] == null) return delete listUpdateInfo[id] saveListUpdateInfo() } export const overwriteListUpdateInfo = async(ids: string[]) => { await initListUpdateInfo() const removedIds = [] for (const id of Object.keys(listUpdateInfo)) { if (ids.includes(id)) continue removedIds.push(id) } for (const id of removedIds) delete listUpdateInfo[id] saveListUpdateInfo() } export const getSearchSetting = async() => { // eslint-disable-next-line require-atomic-updates searchSetting ??= await getSearchSettingFromData() return { ...searchSetting } } export const setSearchSetting = async(setting: Partial) => { if (!searchSetting) await getSearchSetting() let requiredSave = false if (setting.source && searchSetting.source != setting.source) requiredSave = true if (setting.type && searchSetting.type != setting.type) requiredSave = true if (setting.temp_source && searchSetting.temp_source != setting.temp_source) requiredSave = true if (!requiredSave) return searchSetting = Object.assign(searchSetting, setting) saveSearchSettingThrottle() } export const getSongListSetting = async() => { // eslint-disable-next-line require-atomic-updates songListSetting ??= await getSongListSettingFromData() return { ...songListSetting } } export const setSongListSetting = async(setting: Partial) => { if (!songListSetting) await getSongListSetting() songListSetting = Object.assign(songListSetting, setting) saveSongListSettingThrottle() } export const getLeaderboardSetting = async() => { // eslint-disable-next-line require-atomic-updates leaderboardSetting ??= await getLeaderboardSettingFromData() return { ...leaderboardSetting } } export const setLeaderboardSetting = async(setting: Partial) => { if (!leaderboardSetting) await getLeaderboardSetting() leaderboardSetting = Object.assign(leaderboardSetting, setting) saveLeaderboardSettingThrottle() } export const saveViewPrevState = (state: typeof DEFAULT_SETTING['viewPrevState']) => { saveViewPrevStateThrottle(state) } ================================================ FILE: src/renderer/utils/env.js ================================================ const isDev = process.env.NODE_ENV === 'development' export const debug = isDev && true export const debugRequest = isDev && false export const debugDownload = isDev && false ================================================ FILE: src/renderer/utils/index.ts ================================================ import { dateFormat } from '@common/utils/common' export * from '@common/utils/renderer' export * from '@common/utils/nodejs' export * from '@common/utils/common' export * from '@common/utils/tools' /** * 格式化播放数量 * @param {*} num 数字 */ export const formatPlayCount = (num: number): string => { if (num > 100000000) return `${Math.trunc(num / 10000000) / 10}亿` if (num > 10000) return `${Math.trunc(num / 1000) / 10}万` return String(num) } /** * 时间格式化 */ export const dateFormat2 = (time: number): string => { let differ = Math.trunc((Date.now() - time) / 1000) if (differ < 60) { return window.i18n.t('date_format_second', { num: differ }) } else if (differ < 3600) { return window.i18n.t('date_format_minute', { num: Math.trunc(differ / 60) }) } else if (differ < 86400) { return window.i18n.t('date_format_hour', { num: Math.trunc(differ / 3600) }) } else { return dateFormat(time) } } /** * 设置标题 */ let dom_title = document.getElementsByTagName('title')[0] export const setTitle = (title: string | null) => { title ||= 'LX Music' dom_title.innerText = title } // export const getProxyInfo = () => { // return proxy.enable && proxy.host // ? `http://${proxy.username}:${proxy.password}@${proxy.host}:${proxy.port}` // : proxy.envProxy // ? `http://${proxy.envProxy.host}:${proxy.envProxy.port}` // : undefined // } export const getFontSizeWithScreen = (screenWidth: number = window.innerWidth): number => { return screenWidth <= 1440 ? 16 : screenWidth <= 1920 ? 18 : screenWidth <= 2560 ? 20 : screenWidth <= 2560 ? 20 : 22 } export const deduplicationList = (list: T[]): T[] => { const ids = new Set() return list.filter(s => { if (ids.has(s.id)) return false ids.add(s.id) return true }) } export const langS2T = async(str: string) => { return window.lx.worker.main.langS2t(Buffer.from(str).toString('base64')).then(b64 => Buffer.from(b64, 'base64').toString()) } export const decodeName = (str: string | null = '') => { if (!str) return '' return new window.DOMParser().parseFromString(str, 'text/html').body.textContent } ================================================ FILE: src/renderer/utils/ipc.ts ================================================ import { rendererSend, rendererInvoke, rendererOn, rendererOff } from '@common/rendererIpc' import { HOTKEY_RENDERER_EVENT_NAME, WIN_MAIN_RENDERER_EVENT_NAME, CMMON_EVENT_NAME } from '@common/ipcNames' import { type ProgressInfo, type UpdateDownloadedEvent, type UpdateInfo } from 'electron-updater' import { markRaw } from '@common/utils/vueTools' import * as hotKeys from '@common/hotKey' import { APP_EVENT_NAMES, DATA_KEYS, DEFAULT_SETTING } from '@common/constants' type RemoveListener = () => void export const getSetting = async() => { return rendererInvoke(CMMON_EVENT_NAME.get_app_setting) } export const updateSetting = async(setting: Partial) => { await rendererInvoke(CMMON_EVENT_NAME.set_app_setting, setting) } export const onSettingChanged = (listener: LX.IpcRendererEventListenerParams>): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.on_config_change, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.on_config_change, listener) } } export const sendInited = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.inited) } export const getOtherSource = async(id: string): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_other_source, id) } export const saveOtherSource = async(id: string, sourceInfo: LX.Music.MusicInfoOnline[]) => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_other_source, { id, list: sourceInfo, }) } export const clearOtherSource = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_other_source) } export const getOtherSourceCount = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_other_source_count) } // export const updateDislikeInfo = async(dislikeInfo: LX.Dislike.ListItem[]) => { // await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.update_dislike_music_infos, dislikeInfo) // } // export const removeDislikeInfo = async(ids: string[]) => { // await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.remove_dislike_music_infos, ids) // } // export const clearDislikeInfo = async() => { // await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_dislike_music_infos) // } export const getHotKeyConfig = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_hot_key) } export const setIgnoreMouseEvents = (ignore: boolean) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.set_ignore_mouse_events, ignore) } export const getEnvParams = async() => { return rendererInvoke(CMMON_EVENT_NAME.get_env_params) } export const clearEnvParamsDeeplink = () => { rendererSend(CMMON_EVENT_NAME.clear_env_params_deeplink) } export const onDeeplink = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(CMMON_EVENT_NAME.deeplink, listener) return () => { rendererOff(CMMON_EVENT_NAME.deeplink, listener) } } export const checkUpdate = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.update_check) } export const downloadUpdate = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.update_download_update) } export const quitUpdate = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.quit_update) } export const onUpdateAvailable = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.update_available, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.update_available, listener) } } export const onUpdateError = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.update_error, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.update_error, listener) } } export const onUpdateProgress = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.update_progress, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.update_progress, listener) } } export const onUpdateDownloaded = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.update_downloaded, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.update_downloaded, listener) } } export const onUpdateNotAvailable = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.update_not_available, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.update_not_available, listener) } } export const importUserApi = async(fileText: string) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.import_user_api, fileText) } export const setUserApi = async(source: LX.UserApi.UserApiSetApiParams): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.set_user_api, source) } export const removeUserApi = async(ids: string[]) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.remove_user_api, ids) } export const onShowUserApiUpdateAlert = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.user_api_show_update_alert, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.user_api_show_update_alert, listener) } } export const setAllowShowUserApiUpdateAlert = async(id: string, enable: boolean): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.user_api_set_allow_update_alert, { id, enable }) } export const onUserApiStatus = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.user_api_status, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.user_api_status, listener) } } export const getUserApiList = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_user_api_list) } export const sendUserApiRequest = async({ requestKey, data }: LX.UserApi.UserApiRequestParams): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.request_user_api, { requestKey, data, }) } export const userApiRequestCancel = (requestKey: LX.UserApi.UserApiRequestCancelParams) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.request_user_api_cancel, requestKey) } // export const setDesktopLyricInfo = (type, data, info) => { // rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.set_lyric_info, { // type, // data, // info, // }) // } // export const onGetDesktopLyricInfo = callback => { // rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_info, callback) // return () => { // rendererOff(callback) // } // } export const sendPlayerStatus = (status: Partial) => { rendererSend>(WIN_MAIN_RENDERER_EVENT_NAME.player_status, status) } export const sendOpenAPIAction = async(action: LX.OpenAPI.Actions) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.open_api_action, action) } export const saveLastStartInfo = (version: string) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.lastStartInfo, data: version, }) } // 获取最后一次启动时的版本号 export const getLastStartInfo = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.lastStartInfo) } export const savePlayInfo = (playInfo: LX.Player.SavedPlayInfo) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.playInfo, data: playInfo, }) } // 获取上次关闭时的当前歌曲播放信息 export const getPlayInfo = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.playInfo) } export const saveSearchHistoryList = (list: LX.List.SearchHistoryList) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.searchHistoryList, data: list, }) } // 获取搜索历史列表 export const getSearchHistoryList = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.searchHistoryList) } export const saveListPositionInfo = (listPosition: LX.List.ListPositionInfo) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.listScrollPosition, data: listPosition, }) } // 获取搜索历史列表 export const getListPositionInfo = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.listScrollPosition) } export const saveListPrevSelectId = (listPosition: string | null) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.listPrevSelectId, data: listPosition, }) } // 获取上一次选中的列表id export const getListPrevSelectId = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.listPrevSelectId) } export const saveListUpdateInfo = (listPosition: LX.List.ListUpdateInfo) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.listUpdateInfo, data: listPosition, }) } // 获取列表更新记录 export const getListUpdateInfo = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.listUpdateInfo) } export const saveIgnoreVersion = (version: string) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.ignoreVersion, data: version, }) } // 获取忽略更新的版本号 export const getIgnoreVersion = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.ignoreVersion) } export const saveLeaderboardSetting = (source: typeof DEFAULT_SETTING['leaderboard']) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.leaderboardSetting, data: source, }) } export const getLeaderboardSetting = async() => { return (await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.leaderboardSetting)) ?? { ...DEFAULT_SETTING.leaderboard } } export const saveSongListSetting = (setting: typeof DEFAULT_SETTING['songList']) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.songListSetting, data: setting, }) } export const getSongListSetting = async() => { return (await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.songListSetting)) ?? { ...DEFAULT_SETTING.songList } } export const saveSearchSetting = (setting: typeof DEFAULT_SETTING['search']) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.searchSetting, data: setting, }) } export const getSearchSetting = async() => { return (await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.searchSetting)) ?? { ...DEFAULT_SETTING.search } } export const saveViewPrevState = (state: typeof DEFAULT_SETTING['viewPrevState']) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_data, { path: DATA_KEYS.viewPrevState, data: state, }) } export const getViewPrevState = async() => { return (await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_data, DATA_KEYS.viewPrevState)) ?? { ...DEFAULT_SETTING.viewPrevState } } export const getSystemFonts = async() => { return rendererInvoke(CMMON_EVENT_NAME.get_system_fonts).catch(() => { return [] }) } export const getUserSoundEffectEQPresetList = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_eq_preset) } export const saveUserSoundEffectEQPresetList = (list: LX.SoundEffect.EQPreset[]) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_eq_preset, list) } export const getUserSoundEffectConvolutionPresetList = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_convolution_preset) } export const saveUserSoundEffectConvolutionPresetList = (list: LX.SoundEffect.ConvolutionPreset[]) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_convolution_preset, list) } // export const getUserSoundEffectPitchShifterPresetList = async() => { // return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_sound_effect_pitch_shifter_preset) // } // export const saveUserSoundEffectPitchShifterPresetList = (list: LX.SoundEffect.PitchShifterPreset[]) => { // rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.save_sound_effect_pitch_shifter_preset, list) // } export const allHotKeys = markRaw({ local: [ { name: hotKeys.HOTKEY_PLAYER.toggle_play.name, action: hotKeys.HOTKEY_PLAYER.toggle_play.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.prev.name, action: hotKeys.HOTKEY_PLAYER.prev.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.next.name, action: hotKeys.HOTKEY_PLAYER.next.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.seekbackward.name, action: hotKeys.HOTKEY_PLAYER.seekbackward.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.seekforward.name, action: hotKeys.HOTKEY_PLAYER.seekforward.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.music_dislike.name, action: hotKeys.HOTKEY_PLAYER.music_dislike.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_COMMON.focusSearchInput.name, action: hotKeys.HOTKEY_COMMON.focusSearchInput.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_COMMON.min.name, action: hotKeys.HOTKEY_COMMON.min.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_COMMON.close.name, action: hotKeys.HOTKEY_COMMON.close.action, type: APP_EVENT_NAMES.winMainName, }, ], global: [ { name: hotKeys.HOTKEY_COMMON.min_toggle.name, action: hotKeys.HOTKEY_COMMON.min_toggle.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_COMMON.hide_toggle.name, action: hotKeys.HOTKEY_COMMON.hide_toggle.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_COMMON.close.name, action: hotKeys.HOTKEY_COMMON.close.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.toggle_play.name, action: hotKeys.HOTKEY_PLAYER.toggle_play.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.prev.name, action: hotKeys.HOTKEY_PLAYER.prev.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.next.name, action: hotKeys.HOTKEY_PLAYER.next.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.seekbackward.name, action: hotKeys.HOTKEY_PLAYER.seekbackward.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.seekforward.name, action: hotKeys.HOTKEY_PLAYER.seekforward.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.volume_up.name, action: hotKeys.HOTKEY_PLAYER.volume_up.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.volume_down.name, action: hotKeys.HOTKEY_PLAYER.volume_down.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.volume_mute.name, action: hotKeys.HOTKEY_PLAYER.volume_mute.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.music_love.name, action: hotKeys.HOTKEY_PLAYER.music_love.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.music_unlove.name, action: hotKeys.HOTKEY_PLAYER.music_unlove.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_PLAYER.music_dislike.name, action: hotKeys.HOTKEY_PLAYER.music_dislike.action, type: APP_EVENT_NAMES.winMainName, }, { name: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_visible.name, action: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_visible.action, type: APP_EVENT_NAMES.winLyricName, }, { name: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_lock.name, action: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_lock.action, type: APP_EVENT_NAMES.winLyricName, }, { name: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_always_top.name, action: hotKeys.HOTKEY_DESKTOP_LYRIC.toggle_always_top.action, type: APP_EVENT_NAMES.winLyricName, }, ], }) export const hotKeySetEnable = async(enable: boolean) => { return rendererInvoke(HOTKEY_RENDERER_EVENT_NAME.enable, enable) } export const hotKeySetConfig = async(config: LX.HotKeyActions) => { return rendererInvoke(HOTKEY_RENDERER_EVENT_NAME.set_config, config) } export const hotKeyGetStatus = async() => { return rendererInvoke(HOTKEY_RENDERER_EVENT_NAME.status) } // 主进程操作播放器状态 export const onPlayerAction = (listener: LX.IpcRendererEventListenerParams<{ action: LX.Player.StatusButtonActions data?: unknown }>): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.player_action_on_button_click, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.player_action_on_button_click, listener) } } // export const setTaskbarThumbnailClip = async(clip: Electron.Rectangle) => { // await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.taskbar_set_thumbnail_clip, clip) // } // 播放器状态更新 通知主进程 export const setPlayerAction = (buttons: LX.TaskBarButtonFlags) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.player_action_set_buttons, buttons) } /** * On Theme Change * @param listener LX.IpcRendererEventListenerParams * @returns RemoveListener Fn */ export const onThemeChange = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(CMMON_EVENT_NAME.theme_change, listener) return () => { rendererOff(CMMON_EVENT_NAME.theme_change, listener) } } /** * 选择路径 */ export const showSelectDialog = async(options: Electron.OpenDialogOptions) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.show_select_dialog, options) } /** * 打开保存对话框 */ export const openSaveDir = async(options: Electron.SaveDialogOptions) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.show_save_dialog, options) } /** * 在资源管理器中定位文件 */ export const openDirInExplorer = async(path: string) => { return rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.open_dir_in_explorer, path) } /** * 获取缓存大小 */ export const getCacheSize = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_cache_size) } /** * 清除缓存 */ export const clearCache = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_cache) } /** * 设置窗口大小 * @param {*} width * @param {*} height */ export const setWindowSize = (width: number, height: number) => { const params: Partial = { width, height, } rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.set_window_size, params) } export const getPlayerLyric = async(musicInfo: LX.Music.MusicInfo) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_palyer_lyric, musicInfo.id) } export const getLyricRaw = async(musicInfo: LX.Music.MusicInfo): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_raw, musicInfo.id) } export const clearLyricRaw = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_lyric_raw) } export const getLyricRawCount = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_raw_count) } export const getLyricEdited = async(musicInfo: LX.Music.MusicInfo): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_edited, musicInfo.id) } export const saveLyric = async(musicInfo: LX.Music.MusicInfo, lyricInfo: LX.Music.LyricInfo | LX.Player.LyricInfo) => { // console.log(musicInfo) if ('rawlrcInfo' in lyricInfo) { const { rawlrcInfo, ...info } = lyricInfo const tasks = [ rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_raw, { id: musicInfo.id, lyrics: rawlrcInfo, }), ] if (info.lyric != rawlrcInfo.lyric) { tasks.push(rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_edited, { id: musicInfo.id, lyrics: info, })) } console.log(tasks) await Promise.all(tasks) } else { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_raw, { id: musicInfo.id, lyrics: lyricInfo, }) } } export const saveLyricEdited = async(musicInfo: LX.Music.MusicInfo, lyricInfo: LX.Music.LyricInfo) => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_lyric_edited, { id: musicInfo.id, lyrics: lyricInfo, }) } export const removeLyricEdited = async(musicInfo: LX.Music.MusicInfo) => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.remove_lyric_edited, musicInfo.id) } export const clearLyric = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_lyric_raw) } export const clearLyricEdited = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_lyric_edited) } export const getLyricEditedCount = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_lyric_edited_count) } export const saveTheme = async(theme: LX.Theme) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_theme, theme) } export const removeTheme = async(id: string) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.remove_theme, id) } export const getThemes = async() => { return rendererInvoke<{ themes: LX.Theme[], userThemes: LX.Theme[], dataPath: string }>(WIN_MAIN_RENDERER_EVENT_NAME.get_themes) } /** * 从缓存获取歌曲URL * @param musicInfo 歌曲信息 * @param type URL音质 * @returns */ export const getMusicUrl = async(musicInfo: LX.Music.MusicInfo, type: LX.Quality): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_music_url, `${musicInfo.id}_${type}`) } /** * 缓存歌曲URL * @param musicInfo 歌曲信息 * @param type URL音质 * @param url 歌曲URL */ export const saveMusicUrl = async(musicInfo: LX.Music.MusicInfo, type: LX.Quality, url: string) => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.save_music_url, { id: `${musicInfo.id}_${type}`, url, }) } /** * 清理所有缓存的歌曲URL */ export const clearMusicUrl = async() => { await rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.clear_music_url) } export const getMusicUrlCount = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.get_music_url_count) } /** * 退出应用 */ export const quitApp = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.quit) } /** * 关闭窗口 */ export const closeWindow = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.close) } /** * 最小化窗口 */ export const minWindow = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.min) } /** * 最大化窗口 */ export const maxWindow = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.max) } /** * 最小化、最大化窗口切换 */ export const minMaxWindowToggle = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.min_toggle) } /** * 显示、隐藏窗口切换 */ export const showHideWindowToggle = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.hide_toggle) } /** * 聚焦窗口 */ export const focusWindow = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.focus) } /** * 是否启用电源锁 */ export const setPowerSaveBlocker = (enabled: boolean) => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.set_power_save_blocker, enabled) } /** * 窗口获取焦点事件 * @param listener * @returns */ export const onFocus = (listener: LX.IpcRendererEventListener): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.focus, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.focus, listener) } } /** * 快捷键触发事件 * @param listener * @returns */ export const onKeyDown = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.key_down, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.key_down, listener) } } /** * 快捷键设置更新事件 * @param listener * @returns */ export const onUpdateHotkey = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.set_hot_key_config, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.set_hot_key_config, listener) } } /** * 设置全屏 * @param isFullscreen 是否全屏 * @returns */ export const setFullScreen = async(isFullscreen: boolean): Promise => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.fullscreen, isFullscreen) } /** * 打开开发者工具 * @returns */ export const openDevTools = () => { rendererSend(WIN_MAIN_RENDERER_EVENT_NAME.open_dev_tools) } /** * 接收同步事件 * @param listener * @returns */ export const onSyncAction = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.sync_action, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.sync_action, listener) } } /** * 发送同步事件 * @param action * @returns */ export const sendSyncAction = async(action: LX.Sync.SyncServiceActions) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.sync_action, action) } /** * 获取同步服务端连接设备历史列表 * @returns */ export const getSyncServerDevices = () => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.sync_get_server_devices) } /** * 移除同步服务端连接设备 * @returns */ export const removeSyncServerDevice = (clientId: string) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.sync_remove_server_device, clientId) } // export const refreshSyncCode = async(): Promise => { // return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.sync_generate_code) // } // export const onSyncStatus = (listener: LX.IpcRendererEventListenerParams): RemoveListener => { // rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.sync_status, listener) // return () => { // rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.sync_status, listener) // } // } /** * 桌面歌词进程创建事件 * @param listener * @returns */ export const onNewDesktopLyricProcess = (listener: LX.IpcRendererEventListener): RemoveListener => { rendererOn(WIN_MAIN_RENDERER_EVENT_NAME.process_new_desktop_lyric_client, listener) return () => { rendererOff(WIN_MAIN_RENDERER_EVENT_NAME.process_new_desktop_lyric_client, listener) } } export const downloadTasksGet = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.download_list_get) } export const downloadTasksCreate = async(list: LX.Download.ListItem[], addMusicLocationType: LX.AddMusicLocationType) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.download_list_add, { list, addMusicLocationType, }) } export const downloadTasksUpdate = async(list: LX.Download.ListItem[]) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.download_list_update, list) } export const downloadTasksRemove = async(ids: string[]) => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.download_list_remove, ids) } export const downloadListClear = async() => { return rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.download_list_clear) } ================================================ FILE: src/renderer/utils/keyBind.ts ================================================ import { isMac } from '@common/utils' const downKeys = new Set() export type KeyActionType = LX.KeyDownEevent['type'] export type Keys = LX.KeyDownEevent['keys'] export type Key = LX.KeyDownEevent['key'] export type EventKey = LX.KeyDownEevent['eventKey'] export type Event = LX.KeyDownEevent['event'] const handleEvent = (type: KeyActionType, event: LX.KeyEvent, keys: Keys, isEditing: boolean) => { let eventKey = event.key if (isMac) { let index = keys.indexOf('meta') if (index > -1) keys.splice(index, 1, 'mod') if (eventKey == 'Meta') eventKey = 'mod' } else { let index = keys.indexOf('ctrl') if (index > -1) keys.splice(index, 1, 'mod') if (eventKey == 'Control') eventKey = 'mod' } let key = keys.join('+') switch (type) { case 'down': downKeys.add(key) break case 'up': downKeys.delete(key) break } handleSendEvent(key, eventKey, type, event, keys, isEditing) } // 修饰键处理 const eventModifiers = (event: LX.KeyEvent): string[] => { let modifiers: string[] = [] if (event.ctrlKey) modifiers.push('ctrl') if (event.shiftKey) modifiers.push('shift') if (event.altKey) modifiers.push('alt') if (event.metaKey) modifiers.push('meta') return modifiers } // 是否忽略事件(表单元素等默认忽略) const assertStopCallback = (element: HTMLElement) => { // if the element has the class "keybind" then no need to stop if (element.classList.contains('key-bind')) return false // stop for input, select, and textarea switch (element.tagName) { case 'INPUT': case 'SELECT': case 'TEXTAREA': return true default: return !!element.isContentEditable } } const handleKeyDown = (event: LX.KeyEvent) => { // if (assertStopCallback(event.target)) return // event.preventDefault() let keys = eventModifiers(event) switch (event.key) { case 'Control': case 'Alt': case 'Meta': case 'Shift': break case ' ': keys.push('space') break default: keys.push((event.code.includes('Numpad') ? event.code.replace(/^Numpad(\w{1,3})\w*$/i, 'num$1') : event.key).toLowerCase()) break } handleEvent('down', event, keys, event.target ? assertStopCallback(event.target as HTMLElement) : false) } const handleKeyUp = (event: LX.KeyEvent) => { // if (assertStopCallback(event.target)) return event.preventDefault() let keys = eventModifiers(event) switch (event.key) { case 'Control': keys.push('ctrl') break case ' ': keys.push('space') break default: keys.push((event.code.includes('Numpad') ? event.code.replace(/^Numpad(\w{1,3})\w*$/i, 'num$1') : event.key).toLowerCase()) break } handleEvent('up', event, keys, event.target ? assertStopCallback(event.target as HTMLElement) : false) } type HandleSendEvent = (key: Key, eventKey: EventKey, type: KeyActionType, event: Event, keys: Keys, isEditing: boolean) => void let handleSendEvent: HandleSendEvent const bindKey = (handle: HandleSendEvent = () => {}) => { handleSendEvent = handle document.addEventListener('keydown', handleKeyDown) document.addEventListener('keyup', handleKeyUp) } const unbindKey = () => { document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keyup', handleKeyUp) } const clearDownKeys = () => { let keys = Array.from(downKeys) for (let i = keys.length - 1; i > -1; i--) { handleSendEvent(keys[i], keys[i], 'up', null, [keys[i]], false) } downKeys.clear() } export default { bindKey, unbindKey, clearDownKeys, } ================================================ FILE: src/renderer/utils/message.ts ================================================ export const requestMsg = { fail: '请求异常😮,可以多试几次,若还是不行就换一首吧。。。', unachievable: '哦No😱...接口无法访问了!', timeout: '请求超时', // unachievable: '哦No😱...接口无法访问了!已帮你切换到临时接口,重试下看能不能播放吧~', notConnectNetwork: '无法连接到服务器', cancelRequest: '取消http请求', tooManyRequests: '服务器繁忙', } as const ================================================ FILE: src/renderer/utils/music.ts ================================================ import { checkPath, joinPath, extname, basename, readFile, getFileStats } from '@common/utils/nodejs' import { formatPlayTime } from '@common/utils/common' import type { IComment } from 'music-metadata/lib/type' import { decodeKrc } from '@common/utils/lyricUtils/kg' export const checkDownloadFileAvailable = async(musicInfo: LX.Download.ListItem, savePath: string): Promise => { return musicInfo.isComplate && !/\.ape$/.test(musicInfo.metadata.fileName) && (await checkPath(musicInfo.metadata.filePath) || await checkPath(joinPath(savePath, musicInfo.metadata.fileName))) } export const checkLocalFileAvailable = async(musicInfo: LX.Music.MusicInfoLocal): Promise => { return checkPath(musicInfo.meta.filePath) } /** * 检查音乐文件是否存在 * @param musicInfo * @param savePath */ export const checkMusicFileAvailable = async(musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, savePath: string): Promise => { if ('progress' in musicInfo) { return checkDownloadFileAvailable(musicInfo, savePath) } else if (musicInfo.source == 'local') { return checkLocalFileAvailable(musicInfo) } else return true } export const getDownloadFilePath = async(musicInfo: LX.Download.ListItem, savePath: string): Promise => { if (musicInfo.isComplate && !/\.ape$/.test(musicInfo.metadata.fileName)) { if (await checkPath(musicInfo.metadata.filePath)) return musicInfo.metadata.filePath const path = joinPath(savePath, musicInfo.metadata.fileName) if (await checkPath(path)) return path } return '' } export const getLocalFilePath = async(musicInfo: LX.Music.MusicInfoLocal): Promise => { return (await checkPath(musicInfo.meta.filePath)) ? musicInfo.meta.filePath : '' } /** * 获取音乐文件路径 * @param musicInfo * @param savePath * @returns */ export const getMusicFilePath = async(musicInfo: LX.Music.MusicInfo | LX.Download.ListItem, savePath: string): Promise => { if ('progress' in musicInfo) { return getDownloadFilePath(musicInfo, savePath) } else if (musicInfo.source == 'local') { return getLocalFilePath(musicInfo) } return '' } /** * 创建本地音乐信息对象 * @param path 文件路径 * @returns */ export const createLocalMusicInfo = async(path: string): Promise => { if (!await checkPath(path)) return null const { parseFile } = await import('music-metadata') let metadata try { metadata = await parseFile(path) } catch (err) { console.log(err) return null } // console.log(metadata) let ext = extname(path) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let name = (metadata.common.title || basename(path, ext)).trim() let singer = metadata.common.artists?.length ? metadata.common.artists.map(a => a.trim()).join('、') : '' let interval = metadata.format.duration ? formatPlayTime(metadata.format.duration) : '' let albumName = metadata.common.album?.trim() ?? '' return { id: path, name, singer, source: 'local', interval, meta: { albumName, filePath: path, songId: path, picUrl: '', ext: ext.replace(/^\./, ''), }, } } let prevFileInfo: { path: string promise: Promise } = { path: '', promise: Promise.resolve(null), } const getFileMetadata = async(path: string) => { if (prevFileInfo.path == path) return prevFileInfo.promise prevFileInfo.path = path return prevFileInfo.promise = checkPath(path).then(async(isExist) => { return isExist ? import('music-metadata').then(async({ parseFile }) => parseFile(path)).catch(err => { console.log(err) return null }) : null }) } /** * 获取歌曲文件封面图片 * @param path 路径 */ export const getLocalMusicFilePic = async(path: string) => { const filePath = new RegExp('\\' + extname(path) + '$') let picPath = path.replace(filePath, '.jpg') let stats = await getFileStats(picPath) if (stats) return picPath picPath = path.replace(filePath, '.png') stats = await getFileStats(picPath) if (stats) return picPath const metadata = await getFileMetadata(path) if (!metadata) return null const { selectCover } = await import('music-metadata') return selectCover(metadata.common.picture) } // const timeExp = /^\[([\d:.]*)\]{1}/ /** * 解析歌词文件,分离可能存在的翻译、罗马音歌词 * @param lrc 歌词内容 * @returns */ // export const parseLyric = (lrc: string): LX.Music.LyricInfo => { // const lines = lrc.split(/\r\n|\r|\n/) // const lyrics: string[][] = [] // const map = new Map() // for (let i = 0; i < lines.length; i++) { // const line = lines[i].trim() // let result = timeExp.exec(line) // if (result) { // const index = map.get(result[1]) ?? 0 // if (!lyrics[index]) lyrics[index] = [] // lyrics[index].push(line) // map.set(result[1], index + 1) // } else { // if (!lyrics[0]) lyrics[0] = [] // lyrics[0].push(line) // } // } // const lyricInfo: LX.Music.LyricInfo = { // lyric: lyrics[0].join('\n'), // tlyric: '', // } // if (lyrics[1]) lyricInfo.tlyric = lyrics[1].join('\n') // if (lyrics[2]) lyricInfo.rlyric = lyrics[2].join('\n') // return lyricInfo // } /** * 获取歌曲文件歌词 * @param path 路径 */ export const getLocalMusicFileLyric = async(path: string): Promise => { // 尝试读取同目录下的同名lrc文件 const filePath = new RegExp('\\' + extname(path) + '$') let lrcPath = path.replace(filePath, '.lrc') let stats = await getFileStats(lrcPath) // console.log(lrcPath, stats) if (stats && stats.size < 1024 * 1024 * 10) { const lrcBuf = await readFile(lrcPath) const { detect } = await import('jschardet') const { confidence, encoding } = detect(lrcBuf) console.log('lrc file encoding', confidence, encoding) if (confidence > 0.8) { const iconv = (await import('iconv-lite')).default if (iconv.encodingExists(encoding)) { const lrc = iconv.decode(lrcBuf, encoding) if (lrc) { return { lyric: lrc, } } } } } // 尝试读取同目录下的同名krc文件 lrcPath = path.replace(filePath, '.krc') stats = await getFileStats(lrcPath) console.log(lrcPath, stats?.size) if (stats && stats.size < 1024 * 1024 * 10) { const lrcBuf = await readFile(lrcPath) try { return await decodeKrc(lrcBuf) } catch (e) { console.log(e) } } // 尝试读取文件内歌词 const metadata = await getFileMetadata(path) // console.log(metadata?.common) if (!metadata) return null // let lyricInfo = metadata.common.lyrics?.[0] // if (lyricInfo) { // let lyric: string | undefined // if (typeof lyricInfo == 'object') lyric = lyricInfo.text // else if (typeof lyricInfo == 'string') lyric = lyricInfo // if (lyric && lyric.length > 10) { // return { lyric } // } // } // console.log(metadata) for (const info of Object.values(metadata.native)) { for (const ust of info) { switch (ust.id) { case 'LYRICS': { const value = typeof ust.value == 'string' ? ust.value : (ust as IComment).text if (value && value.length > 10) return { lyric: value } break } case 'USLT': { const value = ust.value as IComment if (value.text && value.text.length > 10) return { lyric: value.text } break } } } } return null } ================================================ FILE: src/renderer/utils/musicSdk/api-source-info.ts ================================================ // Support qualitys: 128k 320k flac wav const sources: Array<{ id: string name: string disabled: boolean supportQualitys: Partial> }> = [ // { // id: 'test', // name: '测试接口', // disabled: false, // supportQualitys: { // kw: ['128k'], // kg: ['128k'], // tx: ['128k'], // wy: ['128k'], // mg: ['128k'], // // bd: ['128k'], // }, // }, // { // id: 'temp', // name: '临时接口', // disabled: false, // supportQualitys: { // kw: ['128k'], // }, // }, ] export default sources ================================================ FILE: src/renderer/utils/musicSdk/api-source.js ================================================ import apiSourceInfo from './api-source-info' import { apiSource, userApi } from '@renderer/store' // import api_temp_kw from './kw/api-temp' // // import api_test_bd from './bd/api-test' // import api_test_tx from './tx/api-test' // import api_test_kg from './kg/api-test' // import api_test_kw from './kw/api-test' // import api_test_mg from './mg/api-test' // import api_test_wy from './wy/api-test' const allApi = { // temp_kw: api_temp_kw, // // test_bd: api_test_bd, // test_tx: api_test_tx, // test_kg: api_test_kg, // test_kw: api_test_kw, // test_mg: api_test_mg, // test_wy: api_test_wy, } const apiList = {} const supportQuality = {} for (const api of apiSourceInfo) { supportQuality[api.id] = api.supportQualitys for (const source of Object.keys(api.supportQualitys)) { apiList[`${api.id}_api_${source}`] = allApi[`${api.id}_${source}`] } } const getAPI = source => apiList[`${apiSource.value}_api_${source}`] const apis = source => { if (/^user_api/.test(apiSource.value)) return userApi.apis[source] let api = getAPI(source) if (api) return api throw new Error('Api is not found') } export { apis, supportQuality } ================================================ FILE: src/renderer/utils/musicSdk/bd/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_test = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/bd/${songInfo.songmid}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, } export default api_test ================================================ FILE: src/renderer/utils/musicSdk/bd/hotSearch.js ================================================ import { httpFetch } from '../../request' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) const _requestObj = httpFetch('http://musicapi.qianqian.com/v1/restserver/ting?from=android&version=7.0.2.0&channel=ppzs&operator=0&method=baidu.ting.search.hot', { method: 'get', headers: { 'User-Agent': 'android_7.0.2.0;baiduyinyue', }, }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.error_code !== 22000) throw new Error('获取热搜词失败') // console.log(body, statusCode) return { source: 'bd', list: this.filterList(body.result) } }, filterList(rawList) { return rawList.map(item => item.word) }, } ================================================ FILE: src/renderer/utils/musicSdk/bd/index.js ================================================ import leaderboard from './leaderboard' import { apis } from '../api-source' import musicInfo from './musicInfo' import songList from './songList' import { httpFetch } from '../../request' import musicSearch from './musicSearch' import hotSearch from './hotSearch' const bd = { leaderboard, songList, musicSearch, hotSearch, getMusicUrl(songInfo, type) { return apis('bd').getMusicUrl(songInfo, type) }, getPic(songInfo) { const requestObj = this.getMusicInfo(songInfo) return requestObj.promise.then(info => info.pic_premium) }, getLyric(songInfo) { const requestObj = this.getMusicInfo(songInfo) requestObj.promise = requestObj.promise.then(info => httpFetch(info.lrclink).promise.then(resp => ({ lyric: resp.body, tlyric: '' }))) return requestObj }, // getLyric(songInfo) { // return apis('bd').getLyric(songInfo) // }, // getPic(songInfo) { // return apis('bd').getPic(songInfo) // }, getMusicInfo(songInfo) { return musicInfo.getMusicInfo(songInfo.songmid) }, getMusicDetailPageUrl(songInfo) { return `http://music.taihe.com/song/${songInfo.songmid}` }, } export default bd ================================================ FILE: src/renderer/utils/musicSdk/bd/leaderboard.js ================================================ import { httpFetch } from '../../request' // import { formatPlayTime } from '../../index' const boardList = [ // { id: 'bd__601', name: '歌单榜', bangid: '601' }, { id: 'bd__2', name: '热歌榜', bangid: '2' }, { id: 'bd__20', name: '华语金曲榜', bangid: '20' }, { id: 'bd__25', name: '网络歌曲榜', bangid: '25' }, { id: 'bd__1', name: '新歌榜', bangid: '1' }, { id: 'bd__21', name: '欧美金曲榜', bangid: '21' }, { id: 'bd__200', name: '原创音乐榜', bangid: '200' }, { id: 'bd__22', name: '经典老歌榜', bangid: '22' }, { id: 'bd__24', name: '影视金曲榜', bangid: '24' }, { id: 'bd__23', name: '情歌对唱榜', bangid: '23' }, { id: 'bd__11', name: '摇滚榜', bangid: '11' }, { id: 'bd__105', name: '好童星榜', bangid: '105' }, { id: 'bd__106', name: '雅克•藏羌彝原创音乐榜', bangid: '106' }, ] export default { limit: 20, list: [ { id: 'bdrgb', name: '热歌榜', bangid: '2', }, { id: 'bdxgb', name: '新歌榜', bangid: '1', }, { id: 'bdycb', name: '原创榜', bangid: '200', }, { id: 'bdhyjqb', name: '华语榜', bangid: '20', }, { id: 'bdomjqb', name: '欧美榜', bangid: '21', }, { id: 'bdwugqb', name: '网络榜', bangid: '25', }, { id: 'bdjdlgb', name: '老歌榜', bangid: '22', }, { id: 'bdysjqb', name: '影视金曲榜', bangid: '24', }, { id: 'bdqgdcb', name: '情歌对唱榜', bangid: '23', }, { id: 'bdygb', name: '摇滚榜', bangid: '11', }, ], getUrl(id, p) { return `http://musicmini.qianqian.com/2018/static/bangdan/bangdanList_${id}_${p}.html` }, regExps: { item: /data-song="({.+?})"/g, info: /{total[\s:]+"(\d+)", size[\s:]+"(\d+)", page[\s:]+"(\d+)"}/, }, getData(url) { const requestObj = httpFetch(url) return requestObj.promise }, filterData(rawList) { // console.log(rawList) return rawList.map(item => { const types = [] const _types = {} let size = null types.push({ type: '128k', size }) _types['128k'] = { size, } if (item.biaoshi) { types.push({ type: '320k', size }) _types['320k'] = { size, } types.push({ type: 'flac', size }) _types.flac = { size, } } // types.reverse() return { singer: item.song_artist.replace(',', '、'), name: item.song_title, albumName: item.album_title, albumId: item.album_id, source: 'bd', interval: '', songmid: item.song_id, img: null, lrc: null, types, _types, typeUrl: {}, } }) }, parseData(rawData) { // return rawData.map(item => JSON.parse(item.replace(this.regExps.item, '$1').replace(/"/g, '"').replace(/\\\//g, '/').replace(/(@s_1,w_)\d+(,h_)\d+/, '$1500$2500'))) return rawData.map(item => JSON.parse(item.replace(this.regExps.item, '$1').replace(/"/g, '"').replace(/\\\//g, '/'))) }, async getBoards(retryNum = 0) { this.list = boardList return { list: boardList, source: 'bd', } }, getList(bangid, page, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) return this.getData(this.getUrl(bangid, page)).then(({ body }) => { let result = body.match(this.regExps.item) if (!result) return this.getList(bangid, page, retryNum) let info = body.match(this.regExps.info) if (!info) return this.getList(bangid, page, retryNum) const list = this.filterData(this.parseData(result)) this.limit = parseInt(info[2]) return { total: parseInt(info[1]), list, limit: this.limit, page: parseInt(info[3]), source: 'bd', } }) }, } ================================================ FILE: src/renderer/utils/musicSdk/bd/musicInfo.js ================================================ import { httpFetch } from '../../request' export default { cache: {}, getMusicInfo(songmid) { if (this.cache[songmid]) { return { promise: Promise.resolve(this.cache[songmid]) } } const requestObj = httpFetch(`https://musicapi.qianqian.com/v1/restserver/ting?method=baidu.ting.song.getSongLink&format=json&from=bmpc&version=1.0.0&version_d=11.1.6.0&songid=${songmid}&type=1&res=1&s_protocol=1&aac=2&project=tpass`) requestObj.promise = requestObj.promise.then(({ body }) => { // console.log(body) if (body.error_code == 22000) { this.cache[songmid] = body.result.songinfo return body.result.songinfo } return Promise.reject(new Error('获取音乐信息失败')) }) return requestObj }, } ================================================ FILE: src/renderer/utils/musicSdk/bd/musicSearch.js ================================================ // import '../../polyfill/array.find' import { httpFetch } from '../../request' import { formatPlayTime } from '../../index' // import { debug } from '../../utils/env' // import { formatSinger } from './util' export default { limit: 30, total: 0, page: 0, allPage: 1, musicSearch(str, page, limit) { const searchRequest = httpFetch(`http://tingapi.ting.baidu.com/v1/restserver/ting?from=android&version=5.6.5.6&method=baidu.ting.search.merge&format=json&query=${encodeURIComponent(str)}&page_no=${page}&page_size=${limit}&type=0&data_source=0&use_cluster=1`) return searchRequest.promise.then(({ body }) => body) }, handleResult(rawData) { let ids = new Set() const list = [] if (!rawData) return list rawData.forEach(item => { if (ids.has(item.song_id)) return ids.add(item.song_id) const types = [] const _types = {} let size = null let itemTypes = item.all_rate.split(',') if (itemTypes.includes('128')) { types.push({ type: '128k', size }) _types['128k'] = { size, } } if (itemTypes.includes('320')) { types.push({ type: '320k', size }) _types['320k'] = { size, } } if (itemTypes.includes('flac')) { types.push({ type: 'flac', size }) _types.flac = { size, } } // types.reverse() list.push({ singer: item.author.replace(',', '、'), name: item.title, albumName: item.album_title, albumId: item.album_id, source: 'bd', interval: formatPlayTime(parseInt(item.file_duration)), songmid: item.song_id, img: null, lrc: null, types, _types, typeUrl: {}, }) }) return list }, search(str, page = 1, limit, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) if (limit == null) limit = this.limit return this.musicSearch(str, page, limit).then(result => { if (!result || result.error_code !== 22000) return this.search(str, page, limit, retryNum) let list = this.handleResult(result.result.song_info.song_list) if (list == null) return this.search(str, page, limit, retryNum) this.total = result.result.song_info.total this.page = page this.allPage = Math.ceil(this.total / limit) return Promise.resolve({ list, allPage: this.allPage, limit, total: this.total, source: 'bd', }) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/bd/songList.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, toMD5 } from '../../index' import CryptoJS from 'crypto-js' export default { _requestObj_tags: null, _requestObj_list: null, _requestObj_listRecommend: null, limit_list: 30, limit_song: 10000, successCode: 22000, sortList: [ { name: '最热', id: '1', }, { name: '最新', id: '0', }, ], regExps: { // http://music.taihe.com/songlist/566347741 listDetailLink: /^.+\/songlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/, }, aesPassEncod(jsonData) { let timestamp = Math.floor(Date.now() / 1000) let privateKey = toMD5('baidu_taihe_music_secret_key' + timestamp).substr(8, 16) let key = CryptoJS.enc.Utf8.parse(privateKey) let iv = CryptoJS.enc.Utf8.parse(privateKey) let arrData = [] let strData = '' for (let key in jsonData) arrData.push(key) arrData.sort() for (let i = 0; i < arrData.length; i++) { let key = arrData[i] strData += (i === 0 ? '' : '&') + key + '=' + encodeURIComponent(jsonData[key]) } let JsonFormatter = { stringify(cipherParams) { let jsonObj = { ct: cipherParams.ciphertext.toString(CryptoJS.enc.Base64), } if (cipherParams.iv) { jsonObj.iv = cipherParams.iv.toString() } if (cipherParams.salt) { jsonObj.s = cipherParams.salt.toString() } return jsonObj }, parse(jsonStr) { let jsonObj = JSON.parse(jsonStr) let cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(jsonObj.ct), }) if (jsonObj.iv) { cipherParams.iv = CryptoJS.enc.Hex.parse(jsonObj.iv) } if (jsonObj.s) { cipherParams.salt = CryptoJS.enc.Hex.parse(jsonObj.s) } return cipherParams }, } let encrypted = CryptoJS.AES.encrypt(strData, key, { iv, blockSize: 16, mode: CryptoJS.mode.CBC, format: JsonFormatter, }) let ciphertext = encrypted.toString().ct let sign = toMD5('baidu_taihe_music' + ciphertext + timestamp) let jsonRet = { timestamp, param: ciphertext, sign, } return jsonRet }, createUrl(param, method) { let data = this.aesPassEncod(param) return `http://musicmini.qianqian.com/v1/restserver/ting?method=${method}&time=${Date.now()}×tamp=${data.timestamp}¶m=${data.param}&sign=${data.sign}` }, getTagsUrl() { return this.createUrl({ from: 'qianqianmini', type: 'diy', version: '10.1.8', }, 'baidu.ting.ugcdiy.getChannels') }, getListUrl(sortType, tagName, page) { return this.createUrl({ channelname: tagName || '全部', from: 'qianqianmini', offset: (page - 1) * this.limit_list, order_type: sortType, size: this.limit_list, version: '10.1.8', }, 'baidu.ting.ugcdiy.getChanneldiy') }, getListDetailUrl(list_id, page) { return this.createUrl({ list_id, offset: (page - 1) * this.limit_song, size: this.limit_song, withcount: '1', withsong: '1', }, 'baidu.ting.ugcdiy.getBaseInfo') }, // 获取标签 getTags(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.getTagsUrl()) return this._requestObj_tags.promise.then(({ body }) => { if (body.error_code !== this.successCode) return this.getTags(++tryNum) return { hotTag: this.filterInfoHotTag(body.result.hot), tags: this.filterTagInfo(body.result.tags), source: 'bd', } }) }, filterInfoHotTag(rawList) { return rawList.map(item => ({ name: item, id: item, source: 'bd', })) }, filterTagInfo(rawList) { return rawList.map(type => ({ name: type.first, list: type.second.map(item => ({ parent_id: type.first, parent_name: type.first, id: item, name: item, source: 'bd', })), })) }, // 获取列表数据 getList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page)) return this._requestObj_list.promise.then(({ body }) => { if (body.error_code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) return { list: this.filterList(body.diyInfo), total: body.nums, page, limit: this.limit_list, source: 'bd', } }) }, /** * 格式化播放数量 * @param {*} num */ formatPlayCount(num) { if (num > 100000000) return parseInt(num / 10000000) / 10 + '亿' if (num > 10000) return parseInt(num / 1000) / 10 + '万' return num }, filterList(rawData) { return rawData.map(item => ({ play_count: this.formatPlayCount(item.listen_num), id: String(item.list_id), author: item.username, name: item.title, // time: item.publish_time, img: item.list_pic_large || item.list_pic, grade: item.grade, desc: item.desc || item.tag, source: 'bd', })) }, // 获取歌曲列表内的音乐 getListDetail(id, page, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1') const requestObj_listDetail = httpFetch(this.getListDetailUrl(id, page)) return requestObj_listDetail.promise.then(({ body }) => { if (body.error_code !== this.successCode) return this.getListDetail(id, page, ++tryNum) let listData = this.filterData(body.result.songlist) return { list: listData, page, limit: this.limit_song, total: body.result.song_num, source: 'bd', info: { name: body.result.info.list_title, img: body.result.info.list_pic, desc: body.result.info.list_desc, author: body.result.info.userinfo.username, play_count: this.formatPlayCount(body.result.listen_num), }, } }) }, filterData(rawList) { // console.log(rawList) return rawList.map(item => { const types = [] const _types = {} let size = null let itemTypes = item.all_rate.split(',') if (itemTypes.includes('128')) { types.push({ type: '128k', size }) _types['128k'] = { size, } } if (itemTypes.includes('320')) { types.push({ type: '320k', size }) _types['320k'] = { size, } } if (itemTypes.includes('flac')) { types.push({ type: 'flac', size }) _types.flac = { size, } } // types.reverse() return { singer: item.author.replace(',', '、'), name: item.title, albumName: item.album_title, albumId: item.album_id, source: 'bd', interval: formatPlayTime(parseInt(item.file_duration)), songmid: item.song_id, img: item.pic_s500, lrc: null, types, _types, typeUrl: {}, } }) }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/index.js ================================================ import kw from './kw/index' import kg from './kg/index' import tx from './tx/index' import wy from './wy/index' import mg from './mg/index' import bd from './bd/index' import xm from './xm' import { supportQuality } from './api-source' const sources = { sources: [ { name: '酷我音乐', id: 'kw', }, { name: '酷狗音乐', id: 'kg', }, { name: 'QQ音乐', id: 'tx', }, { name: '网易音乐', id: 'wy', }, { name: '咪咕音乐', id: 'mg', }, { name: '虾米音乐', id: 'xm', }, // { // name: '百度音乐', // id: 'bd', // }, ], kw, kg, tx, wy, mg, bd, xm, } export default { ...sources, init() { const tasks = [] for (let source of sources.sources) { let sm = sources[source.id] sm && sm.init && tasks.push(sm.init()) } return Promise.all(tasks) }, supportQuality, async searchMusic({ name, singer, source: s, limit = 25 }) { const trimStr = str => typeof str == 'string' ? str.trim() : str const musicName = trimStr(name) const tasks = [] const excludeSource = ['xm'] for (const source of sources.sources) { if (!sources[source.id].musicSearch || source.id == s || excludeSource.includes(source.id)) continue tasks.push(sources[source.id].musicSearch.search(`${musicName} ${singer || ''}`.trim(), 1, limit).catch(_ => null)) } return (await Promise.all(tasks)).filter(s => s) }, async findMusic({ name, singer, albumName, interval, source: s }) { const lists = await this.searchMusic({ name, singer, source: s, limit: 25 }) // console.log(lists) // console.log({ name, singer, albumName, interval, source: s }) const singersRxp = /、|&|;|;|\/|,|,|\|/ const sortSingle = singer => singersRxp.test(singer) ? singer.split(singersRxp).sort((a, b) => a.localeCompare(b)).join('、') : (singer || '') const sortMusic = (arr, callback) => { const tempResult = [] for (let i = arr.length - 1; i > -1; i--) { const item = arr[i] if (callback(item)) { delete item.fSinger delete item.fMusicName delete item.fAlbumName delete item.fInterval tempResult.push(item) arr.splice(i, 1) } } tempResult.reverse() return tempResult } const getIntv = (interval) => { if (!interval) return 0 // if (musicInfo._interval) return musicInfo._interval let intvArr = interval.split(':') let intv = 0 let unit = 1 while (intvArr.length) { intv += parseInt(intvArr.pop()) * unit unit *= 60 } return intv } const trimStr = str => typeof str == 'string' ? str.trim() : (str || '') const filterStr = str => typeof str == 'string' ? str.replace(/\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||\/|\]|\[|!|!/g, '') : String(str || '') const fMusicName = filterStr(name).toLowerCase() const fSinger = filterStr(sortSingle(singer)).toLowerCase() const fAlbumName = filterStr(albumName).toLowerCase() const fInterval = getIntv(interval) const isEqualsInterval = (intv) => Math.abs((fInterval || intv) - (intv || fInterval)) < 5 const isIncludesName = (name) => (fMusicName.includes(name) || name.includes(fMusicName)) const isIncludesSinger = (singer) => fSinger ? (fSinger.includes(singer) || singer.includes(fSinger)) : true const isEqualsAlbum = (album) => fAlbumName ? fAlbumName == album : true const result = lists.map(source => { for (const item of source.list) { item.name = trimStr(item.name) item.singer = trimStr(item.singer) item.fSinger = filterStr(sortSingle(item.singer).toLowerCase()) item.fMusicName = filterStr(String(item.name ?? '').toLowerCase()) item.fAlbumName = filterStr(String(item.albumName ?? '').toLowerCase()) item.fInterval = getIntv(item.interval) // console.log(fMusicName, item.fMusicName, item.source) if (!isEqualsInterval(item.fInterval)) { item.name = null continue } if (item.fMusicName == fMusicName && isIncludesSinger(item.fSinger)) return item } for (const item of source.list) { if (item.name == null) continue if (item.fSinger == fSinger && isIncludesName(item.fMusicName)) return item } for (const item of source.list) { if (item.name == null) continue if (isEqualsAlbum(item.fAlbumName) && isIncludesSinger(item.fSinger) && isIncludesName(item.fMusicName)) return item } return null }).filter(s => s) const newResult = [] if (result.length) { newResult.push(...sortMusic(result, item => item.fSinger == fSinger && item.fMusicName == fMusicName && item.interval == interval)) newResult.push(...sortMusic(result, item => item.fMusicName == fMusicName && item.fSinger == fSinger && item.fAlbumName == fAlbumName)) newResult.push(...sortMusic(result, item => item.fSinger == fSinger && item.fMusicName == fMusicName)) newResult.push(...sortMusic(result, item => item.fMusicName == fMusicName && item.interval == interval)) newResult.push(...sortMusic(result, item => item.fSinger == fSinger && item.interval == interval)) newResult.push(...sortMusic(result, item => item.interval == interval)) newResult.push(...sortMusic(result, item => item.fMusicName == fMusicName)) newResult.push(...sortMusic(result, item => item.fSinger == fSinger)) newResult.push(...sortMusic(result, item => item.fAlbumName == fAlbumName)) for (const item of result) { delete item.fSinger delete item.fMusicName delete item.fAlbumName delete item.fInterval } newResult.push(...result) } // console.log(newResult) return newResult }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/album.js ================================================ import { getMusicInfosByList } from './musicInfo' import { createHttpFetch } from './util' export default { /** * 通过AlbumId获取专辑信息 * @param {*} id */ async getAlbumInfo(id) { const albumInfoRequest = await createHttpFetch('http://kmrserviceretry.kugou.com/container/v1/album?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589', { method: 'POST', body: { appid: 1005, clienttime: 1681833686, clientver: 11589, data: [{ album_id: id }], fields: 'language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,exclusive,is_publish,trans_param,authors,album_tag', isBuy: 0, key: 'e6f3306ff7e2afb494e89fbbda0becbf', mid: '22945702112173152889429073101964063697', show_album_tag: 0, }, }) if (!albumInfoRequest) return Promise.reject(new Error('get album info failed.')) const albumInfo = albumInfoRequest[0] return { name: albumInfo.album_name, image: albumInfo.sizable_cover.replace('{size}', 240), desc: albumInfo.intro, authorName: albumInfo.author_name, // play_count: this.formatPlayCount(info.count), } }, /** * 通过AlbumId获取专辑 * @param {*} id * @param {*} page */ async getAlbumDetail(id, page = 1, limit = 200) { const albumList = await createHttpFetch(`http://mobiles.kugou.com/api/v3/album/song?version=9108&albumid=${id}&plat=0&pagesize=${limit}&area_code=0&page=${page}&with_res_tag=0`) if (!albumList.info) return Promise.reject(new Error('Get album list failed.')) let result = await getMusicInfosByList(albumList.info) const info = await this.getAlbumInfo(id) return { list: result || [], page, limit, total: albumList.total, source: 'kg', info: { name: info.name, img: info.image, desc: info.desc, author: info.authorName, // play_count: this.formatPlayCount(info.count), }, } }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_test = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/kg/${songInfo._types[type].hash}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, getPic(songInfo) { const requestObj = httpFetch(`http://ts.tempmusics.tk/pic/kg/${songInfo.hash}`, { method: 'get', timeout, headers, family: 4, }) requestObj.promise = requestObj.promise.then(({ body }) => { return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail)) }) return requestObj }, getLyric(songInfo) { const requestObj = httpFetch(`http://ts.tempmusics.tk/lrc/kg/${songInfo.hash}`, { method: 'get', timeout, headers, family: 4, }) requestObj.promise = requestObj.promise.then(({ body }) => { return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail)) }) return requestObj }, } export default api_test ================================================ FILE: src/renderer/utils/musicSdk/kg/comment.js ================================================ import { httpFetch } from '../../request' import { decodeName, dateFormat2 } from '../../index' import { signatureParams } from './util' // import { getMusicInfoRaw } from './musicInfo' export default { _requestObj: null, _requestObj2: null, async getComment({ hash }, page = 1, limit = 20) { if (this._requestObj) this._requestObj.cancelHttp() // const res_id = (await getMusicInfoRaw(hash)).classification?.[0]?.res_id // if (!res_id) throw new Error('获取评论失败') let timestamp = Date.now() const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0` // const params = `appid=1005&clienttime=${timestamp}&clienttoken=0&clientver=11409&code=fc4be23b4e972707f36b8a828a93ba8a&dfid=0&extdata=${hash}&kugouid=0&mid=16249512204336365674023395779019&mixsongid=${res_id}&p=${page}&pagesize=${limit}&uuid=0&ver=10` const _requestObj = httpFetch(`http://m.comment.service.kugou.com/r/v1/rank/newest?${params}&signature=${signatureParams(params)}`, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24', }, }) const { body, statusCode } = await _requestObj.promise // console.log(body) if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败') const total = body.count ?? 0 return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, async getHotComment({ hash }, page = 1, limit = 20) { // console.log(songmid) if (this._requestObj2) this._requestObj2.cancelHttp() let timestamp = Date.now() const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0` // https://github.com/GitHub-ZC/wp_MusicApi/blob/bf9307dd138dc8ac6c4f7de29361209d4f5b665f/routes/v1/kugou/comment.js#L53 const _requestObj2 = httpFetch(`http://m.comment.service.kugou.com/r/v1/rank/topliked?${params}&signature=${signatureParams(params)}`, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24', }, }) const { body, statusCode } = await _requestObj2.promise // console.log(body) if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败') const total = body.count ?? 0 return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, async getReplyComment({ songmid, audioId }, replyId, page = 1, limit = 100) { if (this._requestObj2) this._requestObj2.cancelHttp() songmid = songmid.length == 32 // 修复歌曲ID存储变更导致图片获取失败的问题 ? audioId.split('_')[0] : songmid const _requestObj2 = httpFetch(`http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike&code=fc4be23b4e972707f36b8a828a93ba8a&p=${page}&pagesize=${limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1&appid=1001&childrenid=${songmid}&tid=${replyId}`, { headers: { 'User-Agent': 'Android712-AndroidPhone-8983-18-0-COMMENT-wifi', }, }) const { body, statusCode } = await _requestObj2.promise // console.log(body) if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败') return { source: 'kg', comments: this.filterComment(body.list || []) } }, replaceAt(raw, atList) { atList.forEach((atobj) => { raw = raw.replaceAll(`[at=${atobj.id}]`, `@${atobj.name} `) }) return raw }, filterComment(rawList) { return rawList.map(item => { let data = { id: item.id, text: decodeName((item.atlist ? this.replaceAt(item.content, item.atlist) : item.content) || ''), images: item.images ? item.images.map(i => i.url) : [], location: item.location, time: item.addtime, timeStr: dateFormat2(new Date(item.addtime).getTime()), userName: item.user_name, avatar: item.user_pic, userId: item.user_id, likedCount: item.like.likenum, replyNum: item.reply_num, reply: [], } return item.pcontent ? { id: item.id, text: decodeName(item.pcontent), time: null, userName: item.puser, avatar: null, userId: item.puser_id, likedCount: null, replyNum: null, reply: [data], } : data }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/hotSearch.js ================================================ import { httpFetch } from '../../request' import { decodeName } from '../../index' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) const _requestObj = httpFetch('http://gateway.kugou.com/api/v3/search/hot_tab?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0', { method: 'get', headers: { dfid: '1ssiv93oVqMp27cirf2CvoF1', mid: '156798703528610303473757548878786007104', clienttime: 1584257267, 'x-router': 'msearch.kugou.com', 'user-agent': 'Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi', 'kg-rc': 1, }, }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败') // console.log(body, statusCode) return { source: 'kg', list: this.filterList(body.data.list) } }, filterList(rawList) { const list = [] rawList.forEach(item => { item.keywords.map(k => list.push(decodeName(k.keyword))) }) return list }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/index.js ================================================ import leaderboard from './leaderboard' import { apis } from '../api-source' import songList from './songList' import musicSearch from './musicSearch' import pic from './pic' import lyric from './lyric' import hotSearch from './hotSearch' import comment from './comment' // import tipSearch from './tipSearch' const kg = { // tipSearch, leaderboard, songList, musicSearch, hotSearch, comment, getMusicUrl(songInfo, type) { return apis('kg').getMusicUrl(songInfo, type) }, getLyric(songInfo) { return lyric.getLyric(songInfo) }, // getLyric(songInfo) { // return apis('kg').getLyric(songInfo) // }, getPic(songInfo) { return pic.getPic(songInfo) }, getMusicDetailPageUrl(songInfo) { return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}` }, // getPic(songInfo) { // return apis('kg').getPic(songInfo) // }, } export default kg ================================================ FILE: src/renderer/utils/musicSdk/kg/leaderboard.js ================================================ import { httpFetch } from '../../request' import { decodeName, formatPlayTime, sizeFormate } from '../../index' import { formatSingerName } from '../utils' let boardList = [{ id: 'kg__8888', name: 'TOP500', bangid: '8888' }, { id: 'kg__6666', name: '飙升榜', bangid: '6666' }, { id: 'kg__59703', name: '蜂鸟流行音乐榜', bangid: '59703' }, { id: 'kg__52144', name: '抖音热歌榜', bangid: '52144' }, { id: 'kg__52767', name: '快手热歌榜', bangid: '52767' }, { id: 'kg__24971', name: 'DJ热歌榜', bangid: '24971' }, { id: 'kg__23784', name: '网络红歌榜', bangid: '23784' }, { id: 'kg__44412', name: '说唱先锋榜', bangid: '44412' }, { id: 'kg__31308', name: '内地榜', bangid: '31308' }, { id: 'kg__33160', name: '电音榜', bangid: '33160' }, { id: 'kg__31313', name: '香港地区榜', bangid: '31313' }, { id: 'kg__51341', name: '民谣榜', bangid: '51341' }, { id: 'kg__54848', name: '台湾地区榜', bangid: '54848' }, { id: 'kg__31310', name: '欧美榜', bangid: '31310' }, { id: 'kg__33162', name: 'ACG新歌榜', bangid: '33162' }, { id: 'kg__31311', name: '韩国榜', bangid: '31311' }, { id: 'kg__31312', name: '日本榜', bangid: '31312' }, { id: 'kg__49225', name: '80后热歌榜', bangid: '49225' }, { id: 'kg__49223', name: '90后热歌榜', bangid: '49223' }, { id: 'kg__49224', name: '00后热歌榜', bangid: '49224' }, { id: 'kg__33165', name: '粤语金曲榜', bangid: '33165' }, { id: 'kg__33166', name: '欧美金曲榜', bangid: '33166' }, { id: 'kg__33163', name: '影视金曲榜', bangid: '33163' }, { id: 'kg__51340', name: '伤感榜', bangid: '51340' }, { id: 'kg__35811', name: '会员专享榜', bangid: '35811' }, { id: 'kg__37361', name: '雷达榜', bangid: '37361' }, { id: 'kg__21101', name: '分享榜', bangid: '21101' }, { id: 'kg__46910', name: '综艺新歌榜', bangid: '46910' }, { id: 'kg__30972', name: '酷狗音乐人原创榜', bangid: '30972' }, { id: 'kg__60170', name: '闽南语榜', bangid: '60170' }, { id: 'kg__65234', name: '儿歌榜', bangid: '65234' }, { id: 'kg__4681', name: '美国BillBoard榜', bangid: '4681' }, { id: 'kg__25028', name: 'Beatport电子舞曲榜', bangid: '25028' }, { id: 'kg__4680', name: '英国单曲榜', bangid: '4680' }, { id: 'kg__38623', name: '韩国Melon音乐榜', bangid: '38623' }, { id: 'kg__42807', name: 'joox本地热歌榜', bangid: '42807' }, { id: 'kg__36107', name: '小语种热歌榜', bangid: '36107' }, { id: 'kg__4673', name: '日本公信榜', bangid: '4673' }, { id: 'kg__46868', name: '日本SPACE SHOWER榜', bangid: '46868' }, { id: 'kg__42808', name: 'KKBOX风云榜', bangid: '42808' }, { id: 'kg__60171', name: '越南语榜', bangid: '60171' }, { id: 'kg__60172', name: '泰语榜', bangid: '60172' }, { id: 'kg__59895', name: 'R&B榜', bangid: '59895' }, { id: 'kg__59896', name: '摇滚榜', bangid: '59896' }, { id: 'kg__59897', name: '爵士榜', bangid: '59897' }, { id: 'kg__59898', name: '乡村音乐榜', bangid: '59898' }, { id: 'kg__59900', name: '纯音乐榜', bangid: '59900' }, { id: 'kg__59899', name: '古典榜', bangid: '59899' }, { id: 'kg__22603', name: '5sing音乐榜', bangid: '22603' }, { id: 'kg__21335', name: '繁星音乐榜', bangid: '21335' }, { id: 'kg__33161', name: '古风新歌榜', bangid: '33161' }] export default { listDetailLimit: 100, list: [ { id: 'kgtop500', name: 'TOP500', bangid: '8888', }, { id: 'kgwlhgb', name: '网络榜', bangid: '23784', }, { id: 'kgbsb', name: '飙升榜', bangid: '6666', }, { id: 'kgfxb', name: '分享榜', bangid: '21101', }, { id: 'kgcyyb', name: '纯音乐榜', bangid: '33164', }, { id: 'kggfjqb', name: '古风榜', bangid: '33161', }, { id: 'kgyyjqb', name: '粤语榜', bangid: '33165', }, { id: 'kgomjqb', name: '欧美榜', bangid: '33166', }, { id: 'kgdyrgb', name: '电音榜', bangid: '33160', }, { id: 'kgjdrgb', name: 'DJ热歌榜', bangid: '24971', }, { id: 'kghyxgb', name: '华语新歌榜', bangid: '31308', }, ], getUrl(p, id, limit) { return `http://mobilecdnbj.kugou.com/api/v3/rank/song?version=9108&ranktype=1&plat=0&pagesize=${limit}&area_code=1&page=${p}&rankid=${id}&with_res_tag=0&show_portrait_mv=1` }, regExps: { total: /total: '(\d+)',/, page: /page: '(\d+)',/, limit: /pagesize: '(\d+)',/, listData: /global\.features = (\[.+\]);/, }, _requestBoardsObj: null, getBoardsData() { if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp() this._requestBoardsObj = httpFetch('http://mobilecdnbj.kugou.com/api/v5/rank/list?version=9108&plat=0&showtype=2&parentid=0&apiver=6&area_code=1&withsong=1') return this._requestBoardsObj.promise }, getData(url) { const requestDataObj = httpFetch(url) return requestDataObj.promise }, getSinger(singers) { let arr = [] singers.forEach(singer => { arr.push(singer.author_name) }) return arr.join('、') }, filterData(rawList) { // console.log(rawList) return rawList.map(item => { const types = [] const _types = {} if (item.filesize !== 0) { let size = sizeFormate(item.filesize) types.push({ type: '128k', size, hash: item.hash }) _types['128k'] = { size, hash: item.hash, } } if (item['320filesize'] !== 0) { let size = sizeFormate(item['320filesize']) types.push({ type: '320k', size, hash: item['320hash'] }) _types['320k'] = { size, hash: item['320hash'], } } if (item.sqfilesize !== 0) { let size = sizeFormate(item.sqfilesize) types.push({ type: 'flac', size, hash: item.sqhash }) _types.flac = { size, hash: item.sqhash, } } if (item.filesize_high !== 0) { let size = sizeFormate(item.filesize_high) types.push({ type: 'flac24bit', size, hash: item.hash_high }) _types.flac24bit = { size, hash: item.hash_high, } } return { singer: formatSingerName(item.authors, 'author_name'), name: decodeName(item.songname), albumName: decodeName(item.remark), albumId: item.album_id, songmid: item.audio_id, source: 'kg', interval: formatPlayTime(item.duration), img: null, lrc: null, hash: item.hash, otherSource: null, types, _types, typeUrl: {}, } }) }, filterBoardsData(rawList) { // console.log(rawList) let list = [] for (const board of rawList) { if (board.isvol != 1) continue list.push({ id: 'kg__' + board.rankid, name: board.rankname, bangid: String(board.rankid), }) } return list }, async getBoards(retryNum = 0) { // if (++retryNum > 3) return Promise.reject(new Error('try max num')) // let response // try { // response = await this.getBoardsData() // } catch (error) { // return this.getBoards(retryNum) // } // // console.log(response.body) // if (response.statusCode !== 200 || response.body.errcode !== 0) return this.getBoards(retryNum) // const list = this.filterBoardsData(response.body.data.info) // console.log(list) // // console.log(JSON.stringify(list)) // this.list = list // return { // list, // source: 'kg', // } this.list = boardList return { list: boardList, source: 'kg', } }, async getList(bangid, page, retryNum = 0) { if (++retryNum > 3) throw new Error('try max num') const { body } = await this.getData(this.getUrl(page, bangid, this.listDetailLimit)) if (body.errcode != 0) return this.getList(bangid, page, retryNum) // console.log(body) let total = body.data.total let limit = 100 let listData = this.filterData(body.data.info) // console.log(listData) return { total, list: listData, limit, page, source: 'kg', } }, getDetailPageUrl(id) { if (typeof id == 'string') id = id.replace('kg__', '') return `https://www.kugou.com/yy/rank/home/1-${id}.html` }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/lyric.js ================================================ import { httpFetch } from '../../request' import { decodeKrc } from '@common/utils/lyricUtils/kg' export default { getIntv(interval) { if (!interval) return 0 let intvArr = interval.split(':') let intv = 0 let unit = 1 while (intvArr.length) { intv += (intvArr.pop()) * unit unit *= 60 } return parseInt(intv) }, // getLyric(songInfo, tryNum = 0) { // let requestObj = httpFetch(`http://m.kugou.com/app/i/krc.php?cmd=100&keyword=${encodeURIComponent(songInfo.name)}&hash=${songInfo.hash}&timelength=${songInfo._interval || this.getIntv(songInfo.interval)}&d=0.38664927426725626`, { // headers: { // 'KG-RC': 1, // 'KG-THash': 'expand_search_manager.cpp:852736169:451', // 'User-Agent': 'KuGou2012-9020-ExpandSearchManager', // }, // }) // requestObj.promise = requestObj.promise.then(({ body, statusCode }) => { // if (statusCode !== 200) { // if (tryNum > 5) return Promise.reject(new Error('歌词获取失败')) // let tryRequestObj = this.getLyric(songInfo, ++tryNum) // requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) // return tryRequestObj.promise // } // return { // lyric: body, // tlyric: '', // } // }) // return requestObj // }, searchLyric(name, hash, time, tryNum = 0) { let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, { headers: { 'KG-RC': 1, 'KG-THash': 'expand_search_manager.cpp:852736169:451', 'User-Agent': 'KuGou2012-9020-ExpandSearchManager', }, }) requestObj.promise = requestObj.promise.then(({ body, statusCode }) => { if (statusCode !== 200) { if (tryNum > 5) return Promise.reject(new Error('歌词获取失败')) let tryRequestObj = this.searchLyric(name, hash, time, ++tryNum) requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) return tryRequestObj.promise } if (body.candidates.length) { let info = body.candidates[0] return { id: info.id, accessKey: info.accesskey, fmt: (info.krctype == 1 && info.contenttype != 1) ? 'krc' : 'lrc' } } return null }) return requestObj }, getLyricDownload(id, accessKey, fmt, tryNum = 0) { let requestObj = httpFetch(`http://lyrics.kugou.com/download?ver=1&client=pc&id=${id}&accesskey=${accessKey}&fmt=${fmt}&charset=utf8`, { headers: { 'KG-RC': 1, 'KG-THash': 'expand_search_manager.cpp:852736169:451', 'User-Agent': 'KuGou2012-9020-ExpandSearchManager', }, }) requestObj.promise = requestObj.promise.then(({ body, statusCode }) => { if (statusCode !== 200) { if (tryNum > 5) return Promise.reject(new Error('歌词获取失败')) let tryRequestObj = this.getLyric(id, accessKey, fmt, ++tryNum) requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) return tryRequestObj.promise } switch (body.fmt) { case 'krc': return decodeKrc(body.content) case 'lrc': return { lyric: Buffer.from(body.content, 'base64').toString('utf-8'), tlyric: '', rlyric: '', lxlyric: '', } default: return Promise.reject(new Error(`未知歌词格式: ${body.fmt}`)) } }) return requestObj }, getLyric(songInfo, tryNum = 0) { let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval)) requestObj.promise = requestObj.promise.then(result => { if (!result) return Promise.reject(new Error('Get lyric failed')) let requestObj2 = this.getLyricDownload(result.id, result.accessKey, result.fmt) requestObj.cancelHttp = requestObj2.cancelHttp.bind(requestObj2) return requestObj2.promise }) return requestObj }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/musicInfo.js ================================================ import { decodeName, formatPlayTime, sizeFormate } from '../../index' import { createHttpFetch } from './util' const createGetMusicInfosTask = (hashs) => { let data = { area_code: '1', show_privilege: 1, show_album_info: '1', is_publish: '', appid: 1005, clientver: 11451, mid: '1', dfid: '-', clienttime: Date.now(), key: 'OIlwieks28dk2k092lksi2UIkp', fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname,classification', } let list = hashs let tasks = [] while (list.length) { tasks.push(Object.assign({ data: list.slice(0, 100) }, data)) if (list.length < 100) break list = list.slice(100) } let url = 'http://gateway.kugou.com/v3/album_audio/audio' return tasks.map(task => createHttpFetch(url, { method: 'POST', body: task, headers: { 'KG-THash': '13a3164', 'KG-RC': '1', 'KG-Fake': '0', 'KG-RF': '00869891', 'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi', 'x-router': 'kmr.service.kugou.com', }, }).then(data => data.map(s => s[0]))) } export const filterMusicInfoList = (rawList) => { // console.log(rawList) let ids = new Set() let list = [] rawList.forEach(item => { if (!item) return if (ids.has(item.audio_info.audio_id)) return ids.add(item.audio_info.audio_id) const types = [] const _types = {} if (item.audio_info.filesize !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize)) types.push({ type: '128k', size, hash: item.audio_info.hash }) _types['128k'] = { size, hash: item.audio_info.hash, } } if (item.audio_info.filesize_320 !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_320)) types.push({ type: '320k', size, hash: item.audio_info.hash_320 }) _types['320k'] = { size, hash: item.audio_info.hash_320, } } if (item.audio_info.filesize_flac !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_flac)) types.push({ type: 'flac', size, hash: item.audio_info.hash_flac }) _types.flac = { size, hash: item.audio_info.hash_flac, } } if (item.audio_info.filesize_high !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_high)) types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high }) _types.flac24bit = { size, hash: item.audio_info.hash_high, } } list.push({ singer: decodeName(item.author_name), name: decodeName(item.songname), albumName: decodeName(item.album_info.album_name), albumId: item.album_info.album_id, songmid: item.audio_info.audio_id, source: 'kg', interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000), img: null, lrc: null, hash: item.audio_info.hash, otherSource: null, types, _types, typeUrl: {}, }) }) return list } export const getMusicInfos = async(hashs) => { return filterMusicInfoList(await Promise.all(createGetMusicInfosTask(hashs)).then(data => data.flat())) } export const getMusicInfoRaw = async(hash) => { return Promise.all(createGetMusicInfosTask([{ hash }])).then(data => data.flat()[0]) } export const getMusicInfo = async(hash) => { return getMusicInfos([{ hash }]).then(data => data[0]) } export const getMusicInfosByList = (list) => { return getMusicInfos(list.map(item => ({ hash: item.hash }))) } ================================================ FILE: src/renderer/utils/musicSdk/kg/musicSearch.js ================================================ import { httpFetch } from '../../request' import { decodeName, formatPlayTime, sizeFormate } from '../../index' import { formatSingerName } from '../utils' export default { limit: 30, total: 0, page: 0, allPage: 1, musicSearch(str, page, limit) { const searchRequest = httpFetch(`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`) return searchRequest.promise.then(({ body }) => body) }, filterData(rawData) { const types = [] const _types = {} if (rawData.FileSize !== 0) { let size = sizeFormate(rawData.FileSize) types.push({ type: '128k', size, hash: rawData.FileHash }) _types['128k'] = { size, hash: rawData.FileHash, } } if (rawData.HQFileSize !== 0) { let size = sizeFormate(rawData.HQFileSize) types.push({ type: '320k', size, hash: rawData.HQFileHash }) _types['320k'] = { size, hash: rawData.HQFileHash, } } if (rawData.SQFileSize !== 0) { let size = sizeFormate(rawData.SQFileSize) types.push({ type: 'flac', size, hash: rawData.SQFileHash }) _types.flac = { size, hash: rawData.SQFileHash, } } if (rawData.ResFileSize !== 0) { let size = sizeFormate(rawData.ResFileSize) types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash }) _types.flac24bit = { size, hash: rawData.ResFileHash, } } return { singer: decodeName(formatSingerName(rawData.Singers, 'name')), name: decodeName(rawData.SongName), albumName: decodeName(rawData.AlbumName), albumId: rawData.AlbumID, songmid: rawData.Audioid, source: 'kg', interval: formatPlayTime(rawData.Duration), _interval: rawData.Duration, img: null, lrc: null, otherSource: null, hash: rawData.FileHash, types, _types, typeUrl: {}, } }, handleResult(rawData) { let ids = new Set() const list = [] rawData.forEach(item => { const key = item.Audioid + item.FileHash if (ids.has(key)) return ids.add(key) list.push(this.filterData(item)) for (const childItem of item.Grp) { const key = item.Audioid + item.FileHash if (ids.has(key)) continue ids.add(key) list.push(this.filterData(childItem)) } }) return list }, search(str, page = 1, limit, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) if (limit == null) limit = this.limit // http://newlyric.kuwo.cn/newlyric.lrc?62355680 return this.musicSearch(str, page, limit).then(result => { if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum) let list = this.handleResult(result.data.lists) if (list == null) return this.search(str, page, limit, retryNum) this.total = result.data.total this.page = page this.allPage = Math.ceil(this.total / limit) return Promise.resolve({ list, allPage: this.allPage, limit, total: this.total, source: 'kg', }) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/pic.js ================================================ import { httpFetch } from '../../request' export default { getPic(songInfo) { const requestObj = httpFetch( 'http://media.store.kugou.com/v1/get_res_privilege', { method: 'POST', headers: { 'KG-RC': 1, 'KG-THash': 'expand_search_manager.cpp:852736169:451', 'User-Agent': 'KuGou2012-9020-ExpandSearchManager', }, body: { appid: 1001, area_code: '1', behavior: 'play', clientver: '9020', need_hash_offset: 1, relate: 1, resource: [ { album_audio_id: songInfo.songmid.length == 32 // 修复歌曲ID存储变更导致图片获取失败的问题 ? songInfo.audioId.split('_')[0] : songInfo.songmid, album_id: songInfo.albumId, hash: songInfo.hash, id: 0, name: `${songInfo.singer} - ${songInfo.name}.mp3`, type: 'audio', }, ], token: '', userid: 2626431536, vip: 1, }, }, ) return requestObj.promise.then(({ body }) => { if (body.error_code !== 0) return Promise.reject(new Error('图片获取失败')) let info = body.data[0].info const img = info.imgsize ? info.image.replace('{size}', info.imgsize[0]) : info.image if (!img) return Promise.reject(new Error('Pic get failed')) return img }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/singer.js ================================================ import { getMusicInfosByList } from './musicInfo' import { createHttpFetch } from './util' export default { /** * 获取歌手信息 * @param {*} id */ getInfo(id) { if (id == 0) throw new Error('歌手不存在') // kg源某些歌曲在歌手没被kg收录时返回的歌手id为0 return createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/info?singerid=${id}`).then(body => { if (!body) throw new Error('get singer info faild.') return { source: 'kg', id: body.singerid, info: { name: body.singername, desc: body.intro, avatar: body.imgurl.replace('{size}', 480), gender: body.grade === 1 ? 'man' : 'woman', }, count: { music: body.songcount, album: body.albumcount, }, } }) }, /** * 获取歌手专辑列表 * @param {*} id * @param {*} page * @param {*} limit */ getAlbumList(id, page = 1, limit = 10) { if (id == 0) throw new Error('歌手不存在') return createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/album?singerid=${id}&page=${page}&pagesize=${limit}`).then(body => { if (!body.info) throw new Error('get singer album list faild.') const list = this.filterAlbumList(body.info) return { source: 'kg', list, limit, page, total: body.total, } }) }, /** * 获取歌手歌曲列表 * @param {*} id * @param {*} page * @param {*} limit */ async getSongList(id, page = 1, limit = 100) { if (id == 0) throw new Error('歌手不存在') const body = await createHttpFetch(`http://mobiles.kugou.com/api/v5/singer/song?singerid=${id}&page=${page}&pagesize=${limit}`) if (!body.info) throw new Error('get singer song list faild.') const list = await getMusicInfosByList(body.info) return { source: 'kg', list, limit, page, total: body.total, } }, filterAlbumList(raw) { return raw.map(item => { return { id: item.albumid, count: item.songcount, info: { name: item.albumname, author: item.singername, img: item.replaceAll('{size}', '480'), desc: item.intro, }, } }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/songList.js ================================================ import { httpFetch } from '../../request' import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../../index' import infSign from '@renderer/utils/musicSdk/kg/vendors/infSign.min' import { signatureParams } from './util' const handleSignature = (id, page, limit) => new Promise((resolve, reject) => { infSign({ appid: 1058, type: 0, module: 'playlist', page, pagesize: limit, specialid: id }, null, { useH5: !0, isCDN: !0, callback(i) { resolve(i.signature) }, }) }) export default { _requestObj_tags: null, _requestObj_listInfo: null, _requestObj_list: null, _requestObj_listRecommend: null, listDetailLimit: 10000, currentTagInfo: { id: undefined, info: undefined, }, sortList: [ { name: '推荐', id: '5', }, { name: '最热', id: '6', }, { name: '最新', id: '7', }, { name: '热藏', id: '3', }, { name: '飙升', id: '8', }, ], cache: new Map(), regExps: { listData: /global\.data = (\[.+\]);/, listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/, // https://www.kugou.com/yy/special/single/1067062.html listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/, }, // async getGlobalSpecialId(specialId) { // return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, { // headers: { // 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70', // }, // }).promise.then(({ body }) => { // // console.log(body) // if (!body.data.global_specialid) Promise.reject(new Error('Failed to get global collection id.')) // return body.data.global_specialid // }) // }, // async getListInfoBySpecialId(special_id, retry = 0) { // if (++retry > 2) throw new Error('failed') // return httpFetch(`https://m.kugou.com/plist/list/${special_id}/?json=true`, { // headers: { // 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70', // }, // follow_max: 2, // }).promise.then(({ body }) => { // // console.log(body) // if (!body.info.list) return this.getListInfoBySpecialId(special_id, retry) // let listinfo = body.info.list // return { // listInfo: { // name: listinfo.specialname, // image: listinfo.imgurl.replace('{size}', '150'), // intro: listinfo.intro, // author: listinfo.nickname, // playcount: listinfo.playcount, // total: listinfo.songcount, // }, // globalSpecialId: listinfo.global_specialid, // } // }) // }, // async getSongListDetailByGlobalSpecialId(id, page, limit = 100, retry = 0) { // if (++retry > 2) throw new Error('failed') // console.log(id) // const params = `specialid=0&need_sort=1&module=CloudMusic&clientver=11409&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=1&area_code=1&appid=1005` // return httpFetch(`http://pubsongscdn.tx.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params)}`).promise.then(({ body }) => { // // console.log(body) // if (body.data?.info == null) return this.getSongListDetailByGlobalSpecialId(id, page, limit, retry) // return body.data.info // }) // }, parseHtmlDesc(html) { const prefix = '
' let index = html.indexOf(prefix) if (index < 0) return null const afterStr = html.substring(index + prefix.length) index = afterStr.indexOf('
') if (index < 0) return null return decodeName(afterStr.substring(0, index)) }, async getListDetailBySpecialId(id, page, tryNum = 0) { if (tryNum > 2) throw new Error('try max num') const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise let listData = body.match(this.regExps.listData) let listInfo = body.match(this.regExps.listInfo) if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum) let list = await this.getMusicInfos(JSON.parse(listData[1])) // listData = this.filterData(JSON.parse(listData[1])) let name let pic if (listInfo) { name = listInfo[1] pic = listInfo[2] } let desc = this.parseHtmlDesc(body) return { list, page: 1, limit: 10000, total: list.length, source: 'kg', info: { name, img: pic, desc, // author: body.result.info.userinfo.username, // play_count: formatPlayCount(body.result.listen_num), }, } // const globalSpecialId = await this.getGlobalSpecialId(id) // const limit = 100 // const listData = await this.getSongListDetailByGlobalSpecialId(globalSpecialId, page, limit) // if (!Array.isArray(listData)) // return this.getUserListDetail2(globalSpecialId) // return { // list: this.filterDatav9(listData), // page, // limit, // total: listInfo.total, // source: 'kg', // info: { // name: listInfo.name, // img: listInfo.image, // desc: listInfo.intro, // author: listInfo.author, // play_count: formatPlayCount(listInfo.playcount), // }, // } }, getInfoUrl(tagId) { return tagId ? `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c=${tagId}` : 'http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&' }, getSongListUrl(sortId, tagId, page) { if (tagId == null) tagId = '' return `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t=${sortId}&c=${tagId}&p=${page}` }, getSongListDetailUrl(id) { return `http://www2.kugou.kugou.com/yueku/v9/special/single/${id}-5-9999.html` }, filterInfoHotTag(rawData) { const result = [] if (rawData.status !== 1) return result for (const key of Object.keys(rawData.data)) { let tag = rawData.data[key] result.push({ id: tag.special_id, name: tag.special_name, source: 'kg', }) } return result }, filterTagInfo(rawData) { const result = [] for (const name of Object.keys(rawData)) { result.push({ name, list: rawData[name].data.map(tag => ({ parent_id: tag.parent_id, parent_name: tag.pname, id: tag.id, name: tag.name, source: 'kg', })), }) } return result }, getSongList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_list = httpFetch( this.getSongListUrl(sortId, tagId, page), ) return this._requestObj_list.promise.then(({ body }) => { if (!body || body.status !== 1) return this.getSongList(sortId, tagId, page, ++tryNum) return this.filterList(body.special_db) }) }, getSongListRecommend(tryNum = 0) { if (this._requestObj_listRecommend) this._requestObj_listRecommend.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_listRecommend = httpFetch( 'http://everydayrec.service.kugou.com/guess_special_recommend', { method: 'post', headers: { 'User-Agent': 'KuGou2012-8275-web_browser_event_handler', }, body: { appid: 1001, clienttime: 1566798337219, clientver: 8275, key: 'f1f93580115bb106680d2375f8032d96', mid: '21511157a05844bd085308bc76ef3343', platform: 'pc', userid: '262643156', return_min: 6, return_max: 15, }, }, ) return this._requestObj_listRecommend.promise.then(({ body }) => { if (body.status !== 1) return this.getSongListRecommend(++tryNum) return this.filterList(body.data.special_list) }) }, filterList(rawData) { return rawData.map(item => ({ play_count: item.total_play_count || formatPlayCount(item.play_count), id: 'id_' + item.specialid, author: item.nickname, name: item.specialname, time: dateFormat(item.publish_time || item.publishtime, 'Y-M-D'), img: item.img || item.imgurl, total: item.songcount, grade: item.grade, desc: item.intro, source: 'kg', })) }, async createHttp(url, options, retryNum = 0) { if (retryNum > 2) throw new Error('try max num') let result try { result = await httpFetch(url, options).promise } catch (err) { console.log(err) return this.createHttp(url, options, ++retryNum) } // console.log(result.statusCode, result.body) if (result.statusCode !== 200 || ( (result.body.error_code !== undefined ? result.body.error_code : result.body.errcode !== undefined ? result.body.errcode : result.body.err_code ) !== 0) ) return this.createHttp(url, options, ++retryNum) if (result.body.data) return result.body.data if (Array.isArray(result.body.info)) return result.body return result.body.info }, createTask(hashs) { let data = { area_code: '1', show_privilege: 1, show_album_info: '1', is_publish: '', appid: 1005, clientver: 11451, mid: '1', dfid: '-', clienttime: Date.now(), key: 'OIlwieks28dk2k092lksi2UIkp', fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname', } let list = hashs let tasks = [] while (list.length) { tasks.push(Object.assign({ data: list.slice(0, 100) }, data)) if (list.length < 100) break list = list.slice(100) } let url = 'http://gateway.kugou.com/v2/album_audio/audio' return tasks.map(task => this.createHttp(url, { method: 'POST', body: task, headers: { 'KG-THash': '13a3164', 'KG-RC': '1', 'KG-Fake': '0', 'KG-RF': '00869891', 'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi', 'x-router': 'kmr.service.kugou.com', }, }).then(data => data.map(s => s[0]))) }, async getMusicInfos(list) { return this.filterData2( await Promise.all( this.createTask( this.deDuplication(list) .map(item => ({ hash: item.hash })), )) .then(([...datas]) => datas.flat())) }, async getUserListDetailByCode(id) { const songInfo = await this.createHttp('http://t.kugou.com/command/', { method: 'POST', headers: { 'KG-RC': 1, 'KG-THash': 'network_super_call.cpp:3676261689:379', 'User-Agent': '', }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id }, }) // console.log(songInfo) // type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列 let songList let info = songInfo.info switch (info.type) { case 2: if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id) break default: break } if (info.global_collection_id) return this.getUserListDetail2(info.global_collection_id) if (info.userid != null) { songList = await this.createHttp('http://www2.kugou.kugou.com/apps/kucodeAndShare/app/', { method: 'POST', headers: { 'KG-RC': 1, 'KG-THash': 'network_super_call.cpp:3676261689:379', 'User-Agent': '', }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } }, }) // console.log(songList) } let list = await this.getMusicInfos(songList || songInfo.list) return { list, page: 1, limit: info.count, total: list.length, source: 'kg', info: { name: info.name, img: (info.img_size && info.img_size.replace('{size}', 240)) || info.img, // desc: body.result.info.list_desc, author: info.username, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetail3(chain, page) { const songInfo = await this.createHttp(`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', }, }) if (!songInfo.list) { if (songInfo.global_collection_id) return this.getUserListDetail2(songInfo.global_collection_id) else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) } let list = await this.getMusicInfos(songInfo.list) // console.log(info, songInfo) return { list, page: 1, limit: this.listDetailLimit, total: list.length, source: 'kg', info: { name: songInfo.info.name, img: songInfo.info.img, // desc: body.result.info.list_desc, author: songInfo.info.username, // play_count: formatPlayCount(info.count), }, } }, deDuplication(datas) { let ids = new Set() return datas.filter(({ hash }) => { if (ids.has(hash)) return false ids.add(hash) return true }) }, async decodeGcid(gcid) { const params = 'dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-' const body = { ret_info: 1, data: [ { id: gcid, id_type: 2, }, ], } const result = await this.createHttp(`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`, { method: 'POST', headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36', Referer: 'https://m.kugou.com/', }, body, }) return result.list[0].global_collection_id }, async getUserListDetailByLink({ info }, link) { let listInfo = info['0'] let total = listInfo.count let tasks = [] let page = 0 while (total) { const limit = total > 90 ? 90 : total total -= limit page += 1 tasks.push(this.createHttp(link.replace(/pagesize=\d+/, 'pagesize=' + limit).replace(/page=\d+/, 'page=' + page), { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', Referer: link, }, }).then(data => data.list.info)) } let result = await Promise.all(tasks).then(([...datas]) => datas.flat()) result = await this.getMusicInfos(result) // console.log(result) return { list: result, page, limit: this.listDetailLimit, total: result.length, source: 'kg', info: { name: listInfo.name, img: listInfo.pic && listInfo.pic.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.list_create_username, // play_count: formatPlayCount(listInfo.count), }, } }, createGetListDetail2Task(id, total) { let tasks = [] let page = 0 while (total) { const limit = total > 300 ? 300 : total total -= limit page += 1 const params = 'appid=1058&global_specialid=' + id + '&specialid=0&plat=0&version=8000&page=' + page + '&pagesize=' + limit + '&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-' tasks.push(this.createHttp(`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`, { headers: { mid: '1586163263991', Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', clienttime: '1586163263991', }, }).then(data => data.info)) } return Promise.all(tasks).then(([...datas]) => datas.flat()) }, async getUserListDetail2(global_collection_id) { let id = global_collection_id if (id.length > 1000) throw new Error('get list error') const params = 'appid=1058&specialid=0&global_specialid=' + id + '&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-' let info = await this.createHttp(`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`, { headers: { mid: '1586163242519', Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', clienttime: '1586163242519', }, }) const songInfo = await this.createGetListDetail2Task(id, info.songcount) let list = await this.getMusicInfos(songInfo) // console.log(info, songInfo, list) return { list, page: 1, limit: this.listDetailLimit, total: list.length, source: 'kg', info: { name: info.specialname, img: info.imgurl && info.imgurl.replace('{size}', 240), desc: info.intro, author: info.nickname, play_count: formatPlayCount(info.playcount), }, } }, async getListInfoByChain(chain) { if (this.cache.has(chain)) return this.cache.get(chain) const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', }, }).promise let result = body.match(/var\sphpParam\s=\s({.+?});/) if (result) result = JSON.parse(result[1]) this.cache.set(chain, result) return result }, async getUserListDetailByPcChain(chain) { let key = `${chain}_pc_list` if (this.cache.has(key)) return this.cache.get(key) const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', }, }).promise let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/) if (result) result = JSON.parse(result[1]) this.cache.set(chain, result) result = await this.getMusicInfos(result) // console.log(info, songInfo) return result }, async getUserListDetail4(songInfo, chain, page) { const limit = 100 const [listInfo, list] = await Promise.all([ this.getListInfoByChain(chain), this.getUserListDetailById(songInfo.id, page, limit), ]) return { list: list || [], page, limit, total: list.length ?? 0, source: 'kg', info: { name: listInfo.specialname, img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.nickname, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetail5(chain) { const [listInfo, list] = await Promise.all([ this.getListInfoByChain(chain), this.getUserListDetailByPcChain(chain), ]) return { list: list || [], page: 1, limit: this.listDetailLimit, total: list.length ?? 0, source: 'kg', info: { name: listInfo.specialname, img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.nickname, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetailById(id, page, limit) { const signature = await handleSignature(id, page, limit) let info = await this.createHttp(`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`, { headers: { Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', }, }) // console.log(info) let result = await this.getMusicInfos(info.info) // console.log(info, songInfo) return result }, async getUserListDetail(link, page, retryNum = 0) { if (retryNum > 3) return Promise.reject(new Error('link try max num')) if (link.includes('#')) link = link.replace(/#.*$/, '') if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) if (link.includes('gcid_')) { let gcid = link.match(/gcid_\w+/)?.[0] if (gcid) { const global_collection_id = await this.decodeGcid(gcid) if (global_collection_id) return this.getUserListDetail2(global_collection_id) } } if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (link.includes('.html')) { if (link.includes('zlist.html')) { link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') if (link.includes('pagesize')) { link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) } else { link += `&pagesize=${this.listDetailLimit}&page=${page}` } } else if (!link.includes('song.html')) return this.getUserListDetail3(link.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } const requestObj_listDetailLink = httpFetch(link, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', Referer: link, }, }) const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise // console.log(body, location) if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum) if (location) { // console.log(location) if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) if (location.includes('gcid_')) { let gcid = link.match(/gcid_\w+/)?.[0] if (gcid) { const global_collection_id = await this.decodeGcid(gcid) if (global_collection_id) return this.getUserListDetail2(global_collection_id) } } if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('.html')) { if (location.includes('zlist.html')) { let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') if (link.includes('pagesize')) { link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) } else { link += `&pagesize=${this.listDetailLimit}&page=${page}` } return this.getUserListDetail(link, page, ++retryNum) } else return this.getUserListDetail3(location.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } // console.log('location', location) return this.getUserListDetail(location, page, ++retryNum) } if (typeof body == 'string') { let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1] if (!global_collection_id) { let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1] if (!gcid) gcid = body.match(/"encode_src_gid":"(\w+)"/)?.[1] if (gcid) global_collection_id = await this.decodeGcid(gcid) } if (!global_collection_id) throw new Error('get list error') return this.getUserListDetail2(global_collection_id) } if (body.errcode !== 0) return this.getUserListDetail(link, page, ++retryNum) return this.getUserListDetailByLink(body, link) }, async getListDetail(id, page) { // 获取歌曲列表内的音乐 id = id.toString() if (id.includes('special/single/')) { id = id.replace(this.regExps.listDetailLink, '$1') } else if (/https?:/.test(id)) { // fix https://www.kugou.com/songlist/xxx/?uid=xxx&chl=qq_client&cover=http%3A%2F%2Fimge.kugou.com%xxx.jpg&iszlist=1 return this.getUserListDetail(id.replace(/^.*?http/, 'http'), page) } else if (/^\d+$/.test(id)) { return this.getUserListDetailByCode(id) } else if (id.startsWith('id_')) { id = id.replace('id_', '') } // if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1') return this.getListDetailBySpecialId(id, page) }, filterData(rawList) { // console.log(rawList) return rawList.map(item => { const types = [] const _types = {} if (item.filesize !== 0) { let size = sizeFormate(item.filesize) types.push({ type: '128k', size, hash: item.hash }) _types['128k'] = { size, hash: item.hash, } } if (item.filesize_320 !== 0) { let size = sizeFormate(item.filesize_320) types.push({ type: '320k', size, hash: item.hash_320 }) _types['320k'] = { size, hash: item.hash_320, } } if (item.filesize_ape !== 0) { let size = sizeFormate(item.filesize_ape) types.push({ type: 'ape', size, hash: item.hash_ape }) _types.ape = { size, hash: item.hash_ape, } } if (item.filesize_flac !== 0) { let size = sizeFormate(item.filesize_flac) types.push({ type: 'flac', size, hash: item.hash_flac }) _types.flac = { size, hash: item.hash_flac, } } return { singer: decodeName(item.singername), name: decodeName(item.songname), albumName: decodeName(item.album_name), albumId: item.album_id, songmid: item.audio_id, source: 'kg', interval: formatPlayTime(item.duration / 1000), img: null, lrc: null, hash: item.hash, types, _types, typeUrl: {}, } }) }, // getSinger(singers) { // let arr = [] // singers?.forEach(singer => { // arr.push(singer.name) // }) // return arr.join('、') // }, // v9 API // filterDatav9(rawList) { // console.log(rawList) // return rawList.map(item => { // const types = [] // const _types = {} // item.relate_goods.forEach(qualityObj => { // if (qualityObj.level === 2) { // let size = sizeFormate(qualityObj.size) // types.push({ type: '128k', size, hash: qualityObj.hash }) // _types['128k'] = { // size, // hash: qualityObj.hash, // } // } else if (qualityObj.level === 4) { // let size = sizeFormate(qualityObj.size) // types.push({ type: '320k', size, hash: qualityObj.hash }) // _types['320k'] = { // size, // hash: qualityObj.hash, // } // } else if (qualityObj.level === 5) { // let size = sizeFormate(qualityObj.size) // types.push({ type: 'flac', size, hash: qualityObj.hash }) // _types.flac = { // size, // hash: qualityObj.hash, // } // } else if (qualityObj.level === 6) { // let size = sizeFormate(qualityObj.size) // types.push({ type: 'flac24bit', size, hash: qualityObj.hash }) // _types.flac24bit = { // size, // hash: qualityObj.hash, // } // } // }) // const nameInfo = item.name.split(' - ') // return { // singer: this.getSinger(item.singerinfo), // name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()), // albumName: decodeName(item.albuminfo.name), // albumId: item.albuminfo.id, // songmid: item.audio_id, // source: 'kg', // interval: formatPlayTime(item.timelen / 1000), // img: null, // lrc: null, // hash: item.hash, // types, // _types, // typeUrl: {}, // } // }) // }, // hash list filter filterData2(rawList) { // console.log(rawList) let ids = new Set() let list = [] rawList.forEach(item => { if (!item) return if (ids.has(item.audio_info.audio_id)) return ids.add(item.audio_info.audio_id) const types = [] const _types = {} if (item.audio_info.filesize !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize)) types.push({ type: '128k', size, hash: item.audio_info.hash }) _types['128k'] = { size, hash: item.audio_info.hash, } } if (item.audio_info.filesize_320 !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_320)) types.push({ type: '320k', size, hash: item.audio_info.hash_320 }) _types['320k'] = { size, hash: item.audio_info.hash_320, } } if (item.audio_info.filesize_flac !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_flac)) types.push({ type: 'flac', size, hash: item.audio_info.hash_flac }) _types.flac = { size, hash: item.audio_info.hash_flac, } } if (item.audio_info.filesize_high !== '0') { let size = sizeFormate(parseInt(item.audio_info.filesize_high)) types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high }) _types.flac24bit = { size, hash: item.audio_info.hash_high, } } list.push({ singer: decodeName(item.author_name), name: decodeName(item.songname), albumName: decodeName(item.album_info.album_name), albumId: item.album_info.album_id, songmid: item.audio_info.audio_id, source: 'kg', interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000), img: null, lrc: null, hash: item.audio_info.hash, otherSource: null, types, _types, typeUrl: {}, }) }) return list }, // 获取列表信息 getListInfo(tagId, tryNum = 0) { if (this._requestObj_listInfo) this._requestObj_listInfo.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_listInfo = httpFetch(this.getInfoUrl(tagId)) return this._requestObj_listInfo.promise.then(({ body }) => { if (body.status !== 1) return this.getListInfo(tagId, ++tryNum) return { limit: body.data.params.pagesize, page: body.data.params.p, total: body.data.params.total, source: 'kg', } }) }, // 获取列表数据 getList(sortId, tagId, page) { let tasks = [this.getSongList(sortId, tagId, page)] tasks.push( this.currentTagInfo.id === tagId ? Promise.resolve(this.currentTagInfo.info) : this.getListInfo(tagId).then(info => { this.currentTagInfo.id = tagId this.currentTagInfo.info = Object.assign({}, info) return info }), ) if (!tagId && page === 1 && sortId === this.sortList[0].id) tasks.push(this.getSongListRecommend()) // 如果是所有类别,则顺便获取推荐列表 return Promise.all(tasks).then(([list, info, recommendList]) => { if (recommendList) list.unshift(...recommendList) return { list, ...info, } }) }, // 获取标签 getTags(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.getInfoUrl()) return this._requestObj_tags.promise.then(({ body }) => { if (body.status !== 1) return this.getTags(++tryNum) return { hotTag: this.filterInfoHotTag(body.data.hotTag), tags: this.filterTagInfo(body.data.tagids), source: 'kg', } }) }, getDetailPageUrl(id) { if (typeof id == 'string') { if (/^https?:\/\//.test(id)) return id id = id.replace('id_', '') } return `https://www.kugou.com/yy/special/single/${id}.html` }, search(text, page, limit = 20) { // http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0 // return httpFetch(`http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5`) return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`) .promise.then(({ body }) => { if (body.errcode != 0) throw new Error('filed') // console.log(body.data.info) return { list: body.data.info.map(item => { return { play_count: formatPlayCount(item.playcount), id: 'id_' + item.specialid, author: item.nickname, name: item.specialname, time: dateFormat(item.publishtime, 'Y-M-D'), img: item.imgurl, grade: item.grade, desc: item.intro, total: item.songcount, source: 'kg', } }), limit, total: body.data.total, source: 'kg', } }) }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/kg/temp/musicSearch-new.js ================================================ import { decodeName, formatPlayTime, sizeFormate } from '../../index' import { signatureParams, createHttpFetch } from './util' import { formatSingerName } from '../../utils' export default { limit: 30, total: 0, page: 0, allPage: 1, musicSearch(str, page, limit) { const sign = signatureParams(`userid=0&area_code=1&appid=1005&dopicfull=1&page=${page}&token=0&privilegefilter=0&requestid=0&pagesize=${limit}&user_labels=&clienttime=0&sec_aggre=1&iscorrection=1&uuid=0&mid=0&keyword=${str}&dfid=-&clientver=11409&platform=AndroidFilter&tag=`, 3) return createHttpFetch(`https://gateway.kugou.com/complexsearch/v3/search/song?userid=0&area_code=1&appid=1005&dopicfull=1&page=${page}&token=0&privilegefilter=0&requestid=0&pagesize=${limit}&user_labels=&clienttime=0&sec_aggre=1&iscorrection=1&uuid=0&mid=0&dfid=-&clientver=11409&platform=AndroidFilter&tag=&keyword=${encodeURIComponent(str)}&signature=${sign}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', referer: 'https://kugou.com', }, }).then(body => body) }, filterList(raw) { let ids = new Set() const list = [] raw.forEach(item => { if (ids.has(item.Audioid)) return ids.add(item.Audioid) const types = [] const _types = {} if (item.FileSize !== 0) { let size = sizeFormate(item.FileSize) types.push({ type: '128k', size, hash: item.FileHash }) _types['128k'] = { size, hash: item.FileHash, } } if (item.HQ != undefined) { let size = sizeFormate(item.HQ.FileSize) types.push({ type: '320k', size, hash: item.HQ.Hash }) _types['320k'] = { size, hash: item.HQ.Hash, } } if (item.SQ != undefined) { let size = sizeFormate(item.SQ.FileSize) types.push({ type: 'flac', size, hash: item.SQ.Hash }) _types.flac = { size, hash: item.SQ.Hash, } } if (item.Res != undefined) { let size = sizeFormate(item.Res.FileSize) types.push({ type: 'flac24bit', size, hash: item.Res.Hash }) _types.flac24bit = { size, hash: item.Res.Hash, } } list.push({ singer: decodeName(formatSingerName(item.Singers)), name: decodeName(item.SongName), albumName: decodeName(item.AlbumName), albumId: item.AlbumID, songmid: item.Audioid, source: 'kg', interval: formatPlayTime(item.Duration), _interval: item.Duration, img: null, lrc: null, otherSource: null, hash: item.FileHash, types, _types, typeUrl: {}, }) }) return list }, handleResult(rawData) { const rawList = [] rawData.forEach(item => { rawList.push(item) item.Grp.forEach(e => rawList.push(e)) }) return this.filterList(rawList) }, search(str, page = 1, limit, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) if (limit == null) limit = this.limit return this.musicSearch(str, page, limit).then(data => { let list = this.handleResult(data.lists) if (!list) return this.search(str, page, limit, retryNum) this.total = data.total this.page = page this.allPage = Math.ceil(this.total / limit) return Promise.resolve({ list, allPage: this.allPage, limit, total: this.total, source: 'kg', }) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/temp/songList-new.js ================================================ import { httpFetch } from '../../../request' import { formatSingerName } from '../../utils' import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../../../index' import { signatureParams, createHttpFetch } from './../util' import { getMusicInfosByList } from '../musicInfo' import album from '../album' export default { _requestObj_tags: null, _requestObj_listInfo: null, _requestObj_list: null, _requestObj_listRecommend: null, listDetailLimit: 10000, currentTagInfo: { id: undefined, info: undefined, }, sortList: [ { name: '推荐', id: '5', }, { name: '最热', id: '6', }, { name: '最新', id: '7', }, { name: '热藏', id: '3', }, { name: '飙升', id: '8', }, ], cache: new Map(), collectionIdListInfoCache: new Map(), regExps: { listData: /global\.data = (\[.+\]);/, listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/, // https://www.kugou.com/yy/special/single/1067062.html listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/, }, /** * 获取歌曲列表内的音乐 * @param {*} id * @param {*} page */ async getListDetail(id, page) { id = id.toString() if (id.includes('special/single/')) id = id.replace(this.regExps.listDetailLink, '$1') // fix https://www.kugou.com/songlist/xxx/?uid=xxx&chl=qq_client&cover=http%3A%2F%2Fimge.kugou.com%xxx.jpg&iszlist=1 if (/https?:/.test(id)) { if (id.includes('#')) id = id.replace(/#.*$/, '') if (id.includes('global_collection_id')) return this.getUserListDetailByCollectionId(id.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (id.includes('chain=')) return this.getUserListDetail3(id.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (id.includes('.html')) { if (id.includes('zlist.html')) { id = id.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') if (id.includes('pagesize')) { id = id.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) } else { id += `&pagesize=${this.listDetailLimit}&page=${page}` } } else if (!id.includes('song.html')) return this.getUserListDetail3(id.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } return this.getUserListDetail(id.replace(/^.*?http/, 'http'), page) } if (/^\d+$/.test(id)) return this.getUserListDetailByCode(id, page) if (id.startsWith('gid_')) return this.getUserListDetailByCollectionId(id.replace('gid_', ''), page) if (id.startsWith('id_')) return this.getUserListDetailBySpecialId(id.replace('id_', ''), page) return new Error('Failed.') }, /** * 获取SpecialId歌单 * @param {*} id */ async getUserListDetailBySpecialId(id, page, tryNum = 0) { if (tryNum > 2) throw new Error('try max num') const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise let listData = body.match(this.regExps.listData) let listInfo = body.match(this.regExps.listInfo) if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum) let list = await getMusicInfosByList(JSON.parse(listData[1])) let name let pic if (listInfo) { name = listInfo[1] pic = listInfo[2] } let desc = this.parseHtmlDesc(body) return { list, page: 1, limit: 10000, total: list.length, source: 'kg', info: { name, img: pic, desc, // author: body.result.info.userinfo.username, // play_count: formatPlayCount(body.result.listen_num), }, } }, parseHtmlDesc(html) { const prefix = '
' let index = html.indexOf(prefix) if (index < 0) return null const afterStr = html.substring(index + prefix.length) index = afterStr.indexOf('
') if (index < 0) return null return decodeName(afterStr.substring(0, index)) }, /** * 使用SpecialId获取CollectionId * @param {*} specialId */ async getCollectionIdBySpecialId(specialId) { return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70', }, }).promise.then(({ body }) => { // console.log('getCollectionIdBySpecialId', body) if (!body.data.global_specialid) return Promise.reject(new Error('Failed to get global collection id.')) return body.data.global_specialid }) }, /** * 获取歌单URL * @param {*} sortId * @param {*} tagId * @param {*} page */ getSongListUrl(sortId, tagId, page) { if (tagId == null) tagId = '' return `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t=${sortId}&c=${tagId}&p=${page}` }, getInfoUrl(tagId) { return tagId ? `http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c=${tagId}` : 'http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&' }, getSongListDetailUrl(id) { return `http://www2.kugou.kugou.com/yueku/v9/special/single/${id}-5-9999.html` }, filterInfoHotTag(rawData) { const result = [] if (rawData.status !== 1) return result for (const key of Object.keys(rawData.data)) { let tag = rawData.data[key] result.push({ id: tag.special_id, name: tag.special_name, source: 'kg', }) } return result }, filterTagInfo(rawData) { const result = [] for (const name of Object.keys(rawData)) { result.push({ name, list: rawData[name].data.map(tag => ({ parent_id: tag.parent_id, parent_name: tag.pname, id: tag.id, name: tag.name, source: 'kg', })), }) } return result }, filterSongList(rawData) { return rawData.map(item => ({ play_count: item.total_play_count || formatPlayCount(item.play_count), id: 'id_' + item.specialid, author: item.nickname, name: item.specialname, time: dateFormat(item.publish_time || item.publishtime, 'Y-M-D'), img: item.img || item.imgurl, total: item.songcount, grade: item.grade, desc: item.intro, source: 'kg', })) }, getSongList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_list = httpFetch( this.getSongListUrl(sortId, tagId, page), ) return this._requestObj_list.promise.then(({ body }) => { if (!body || body.status !== 1) return this.getSongList(sortId, tagId, page, ++tryNum) return this.filterSongList(body.special_db) }) }, getSongListRecommend(tryNum = 0) { if (this._requestObj_listRecommend) this._requestObj_listRecommend.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_listRecommend = httpFetch( 'http://everydayrec.service.kugou.com/guess_special_recommend', { method: 'post', headers: { 'User-Agent': 'KuGou2012-8275-web_browser_event_handler', }, body: { appid: 1001, clienttime: 1566798337219, clientver: 8275, key: 'f1f93580115bb106680d2375f8032d96', mid: '21511157a05844bd085308bc76ef3343', platform: 'pc', userid: '262643156', return_min: 6, return_max: 15, }, }, ) return this._requestObj_listRecommend.promise.then(({ body }) => { if (body.status !== 1) return this.getSongListRecommend(++tryNum) return this.filterSongList(body.data.special_list) }) }, /** * 通过CollectionId获取歌单详情 * @param {*} id */ async getUserListInfoByCollectionId(id) { if (!id || id.length > 1000) return Promise.reject(new Error('get list error')) if (this.collectionIdListInfoCache.has(id)) return this.collectionIdListInfoCache.get(id) const params = `appid=1058&specialid=0&global_specialid=${id}&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-` return createHttpFetch(`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`, { headers: { mid: '1586163242519', Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', clienttime: '1586163242519', }, }).then(body => { let info = { type: body.type, userName: body.nickname, userAvatar: body.user_avatar, imageUrl: body.imgurl, desc: body.intro, name: body.specialname, globalSpecialid: body.global_specialid, total: body.songcount, playCount: body.playcount, } this.collectionIdListInfoCache.set(id, info) return info }) }, /** * 通过SpecialId获取歌单 * @param {*} id */ // async getUserListDetailBySpecialId(id, page = 1, limit = 300) { // if (!id || id.length > 1000) return Promise.reject(new Error('get list error.')) // const listInfo = await this.getListInfoBySpecialId(id) // const params = `specialid=${id}&need_sort=1&module=CloudMusic&clientver=11589&pagesize=${limit}&userid=0&page=${page}&type=0&area_code=1&appid=1005` // return createHttpFetch(`http://pubsongs.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params, 2)}`, { // headers: { // 'User-Agent': 'Android10-AndroidPhone-11589-201-0-playlist-wifi', // }, // }).then(body => { // if (!body.info) return Promise.reject(new Error('Get list failed.')) // const songList = this.filterListByCollectionId(body.info) // return { // list: songList || [], // page, // limit, // total: body.count, // source: 'kg', // info: { // name: listInfo.name, // img: listInfo.image, // desc: listInfo.desc, // // author: listInfo.userName, // // play_count: formatPlayCount(listInfo.playCount), // }, // } // }) // }, /** * 通过CollectionId获取歌单 * @param {*} id */ async getUserListDetailByCollectionId(id, page = 1, limit = 300) { if (!id || id.length > 1000) return Promise.reject(new Error('ID error.')) const listInfo = await this.getUserListInfoByCollectionId(id) const params = `need_sort=1&module=CloudMusic&clientver=11589&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=0&area_code=1&appid=1005` return createHttpFetch(`http://pubsongs.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params, 'android')}`, { headers: { 'User-Agent': 'Android10-AndroidPhone-11589-201-0-playlist-wifi', }, }).then(body => { if (!body.info) return Promise.reject(new Error('Get list failed.')) const songList = this.filterListByCollectionId(body.info) return { list: songList || [], page, limit, total: listInfo.total, source: 'kg', info: { name: listInfo.name, img: listInfo.imageUrl && listInfo.imageUrl.replace('{size}', 240), desc: listInfo.desc, author: listInfo.userName, play_count: formatPlayCount(listInfo.playCount), }, } }) }, /** * 过滤GlobalSpecialId歌单数据 * @param {*} rawData */ filterListByCollectionId(rawData) { let ids = new Set() let list = [] rawData.forEach(item => { if (!item) return if (ids.has(item.hash)) return ids.add(item.hash) const types = [] const _types = {} item.relate_goods.forEach(data => { let size = sizeFormate(data.size) switch (data.level) { case 2: types.push({ type: '128k', size, hash: data.hash }) _types['128k'] = { size, hash: data.hash, } break case 4: types.push({ type: '320k', size, hash: data.hash }) _types['320k'] = { size, hash: data.hash, } break case 5: types.push({ type: 'flac', size, hash: data.hash }) _types.flac = { size, hash: data.hash, } break case 6: types.push({ type: 'flac24bit', size, hash: data.hash }) _types.flac24bit = { size, hash: data.hash, } break } }) list.push({ singer: formatSingerName(item.singerinfo, 'name') || decodeName(item.name).split(' - ')[0].replace(/&/g, '、'), name: decodeName(item.name).split(' - ')[1], albumName: decodeName(item.albuminfo.name), albumId: item.albuminfo.id, songmid: item.audio_id, source: 'kg', interval: formatPlayTime(parseInt(item.timelen) / 1000), img: null, lrc: null, hash: item.hash, otherSource: null, types, _types, typeUrl: {}, }) }) return list }, /** * 通过酷狗码获取歌单 * @param {*} id * @param {*} page */ async getUserListDetailByCode(id, page = 1) { // type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列 const codeData = await createHttpFetch('http://t.kugou.com/command/', { method: 'POST', headers: { 'KG-RC': 1, 'KG-THash': 'network_super_call.cpp:3676261689:379', 'User-Agent': '', }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id }, }) if (!codeData) return Promise.reject(new Error('Get list failed.')) const codeInfo = codeData.info switch (codeInfo.type) { case 2: if (!codeInfo.global_collection_id) return this.getUserListDetailBySpecialId(codeInfo.id, page) break case 3: return album.getAlbumDetail(codeInfo.id, page) } if (codeInfo.global_collection_id) return this.getUserListDetailByCollectionId(codeInfo.global_collection_id, page) if (codeInfo.userid != null) { const songList = await createHttpFetch('http://www2.kugou.kugou.com/apps/kucodeAndShare/app/', { method: 'POST', headers: { 'KG-RC': 1, 'KG-THash': 'network_super_call.cpp:3676261689:379', 'User-Agent': '', }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: codeInfo.id, type: 3, userid: codeInfo.userid, collect_type: 0, page: 1, pagesize: codeInfo.count } }, }) // console.log(songList) let list = await getMusicInfosByList(songList || codeInfo.list) return { list, page: 1, limit: codeInfo.count, total: list.length, source: 'kg', info: { name: codeInfo.name, img: (codeInfo.img_size && codeInfo.img_size.replace('{size}', 240)) || codeInfo.img, // desc: body.result.info.list_desc, author: codeInfo.username, // play_count: formatPlayCount(info.count), }, } } }, async getUserListDetail3(chain, page) { const songInfo = await createHttpFetch(`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', }, }) if (!songInfo.list) { if (songInfo.global_collection_id) return this.getUserListDetailByCollectionId(songInfo.global_collection_id, page) else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) } let list = await getMusicInfosByList(songInfo.list) // console.log(info, songInfo) return { list, page: 1, limit: this.listDetailLimit, total: list.length, source: 'kg', info: { name: songInfo.info.name, img: songInfo.info.img, // desc: body.result.info.list_desc, author: songInfo.info.username, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetailByLink({ info }, link) { let listInfo = info['0'] let total = listInfo.count let tasks = [] let page = 0 while (total) { const limit = total > 90 ? 90 : total total -= limit page += 1 tasks.push(createHttpFetch(link.replace(/pagesize=\d+/, 'pagesize=' + limit).replace(/page=\d+/, 'page=' + page), { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', Referer: link, }, }).then(data => data.list.info)) } let result = await Promise.all(tasks).then(([...datas]) => datas.flat()) result = await getMusicInfosByList(result) // console.log(result) return { list: result, page, limit: this.listDetailLimit, total: result.length, source: 'kg', info: { name: listInfo.name, img: listInfo.pic && listInfo.pic.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.list_create_username, // play_count: formatPlayCount(listInfo.count), }, } }, createGetListDetail2Task(id, total) { let tasks = [] let page = 0 while (total) { const limit = total > 300 ? 300 : total total -= limit page += 1 const params = 'appid=1058&global_specialid=' + id + '&specialid=0&plat=0&version=8000&page=' + page + '&pagesize=' + limit + '&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-' tasks.push(createHttpFetch(`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`, { headers: { mid: '1586163263991', Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', clienttime: '1586163263991', }, }).then(data => data.info)) } return Promise.all(tasks).then(([...datas]) => datas.flat()) }, async getUserListDetail2(global_collection_id) { let id = global_collection_id if (id.length > 1000) throw new Error('get list error') const params = 'appid=1058&specialid=0&global_specialid=' + id + '&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-' let info = await createHttpFetch(`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`, { headers: { mid: '1586163242519', Referer: 'https://m3ws.kugou.com/share/index.php', 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', dfid: '-', clienttime: '1586163242519', }, }) const songInfo = await this.createGetListDetail2Task(id, info.songcount) let list = await getMusicInfosByList(songInfo) // console.log(info, songInfo, list) return { list, page: 1, limit: this.listDetailLimit, total: list.length, source: 'kg', info: { name: info.specialname, img: info.imgurl && info.imgurl.replace('{size}', 240), desc: info.intro, author: info.nickname, play_count: formatPlayCount(info.playcount), }, } }, async getListInfoByChain(chain) { if (this.cache.has(chain)) return this.cache.get(chain) const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', }, }).promise // console.log(body) let result = body.match(/var\sphpParam\s=\s({.+?});/) if (result) result = JSON.parse(result[1]) this.cache.set(chain, result) return result }, async getUserListDetailByPcChain(chain) { let key = `${chain}_pc_list` if (this.cache.has(key)) return this.cache.get(key) const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', }, }).promise let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/) if (result) result = JSON.parse(result[1]) this.cache.set(chain, result) result = await getMusicInfosByList(result) // console.log(info, songInfo) return result }, async getUserListDetail4(songInfo, chain, page) { const limit = 100 const [listInfo, list] = await Promise.all([ this.getListInfoByChain(chain), this.getUserListDetailBySpecialId(songInfo.id, page, limit), ]) return { list: list || [], page, limit, total: list.length ?? 0, source: 'kg', info: { name: listInfo.specialname, img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.nickname, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetail5(chain) { const [listInfo, list] = await Promise.all([ this.getListInfoByChain(chain), this.getUserListDetailByPcChain(chain), ]) return { list: list || [], page: 1, limit: this.listDetailLimit, total: list.length ?? 0, source: 'kg', info: { name: listInfo.specialname, img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240), // desc: body.result.info.list_desc, author: listInfo.nickname, // play_count: formatPlayCount(info.count), }, } }, async getUserListDetail(link, page, retryNum = 0) { if (retryNum > 3) return Promise.reject(new Error('link try max num')) const requestLink = httpFetch(link, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', Referer: link, }, follow_max: 2, }) const { headers: { location }, statusCode, body } = await requestLink.promise // console.log(body, location, statusCode) if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum) if (typeof body == 'string') { if (body.includes('"global_collection_id":')) return this.getUserListDetailByCollectionId(body.replace(/^[\s\S]+?"global_collection_id":"(\w+)"[\s\S]+?$/, '$1'), page) if (body.includes('"albumid":')) return album.getAlbumDetail(body.replace(/^[\s\S]+?"albumid":(\w+)[\s\S]+?$/, '$1'), page) if (body.includes('"album_id":') && link.includes('album/info')) return album.getAlbumDetail(body.replace(/^[\s\S]+?"album_id":(\w+)[\s\S]+?$/, '$1'), page) if (body.includes('list_id = "') && link.includes('album/info')) return album.getAlbumDetail(body.replace(/^[\s\S]+?list_id = "(\w+)"[\s\S]+?$/, '$1'), page) } if (location) { // 概念版分享链接 https://t1.kugou.com/xxx if (location.includes('global_specialid')) return this.getUserListDetailByCollectionId(location.replace(/^.*?global_specialid=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('global_collection_id')) return this.getUserListDetailByCollectionId(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('.html')) { if (location.includes('zlist.html')) { let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') if (link.includes('pagesize')) { link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) } else { link += `&pagesize=${this.listDetailLimit}&page=${page}` } return this.getUserListDetail(link, page, ++retryNum) } else return this.getUserListDetail3(location.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } return this.getUserListDetail(location, page, ++retryNum) } if (body.errcode !== 0) return this.getUserListDetail(link, page, ++retryNum) return this.getUserListDetailByLink(body, link) }, // 获取列表信息 getListInfo(tagId, tryNum = 0) { if (this._requestObj_listInfo) this._requestObj_listInfo.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_listInfo = httpFetch(this.getInfoUrl(tagId)) return this._requestObj_listInfo.promise.then(({ body }) => { if (body.status !== 1) return this.getListInfo(tagId, ++tryNum) return { limit: body.data.params.pagesize, page: body.data.params.p, total: body.data.params.total, source: 'kg', } }) }, // 获取列表数据 getList(sortId, tagId, page) { let tasks = [this.getSongList(sortId, tagId, page)] tasks.push( this.currentTagInfo.id === tagId ? Promise.resolve(this.currentTagInfo.info) : this.getListInfo(tagId).then(info => { this.currentTagInfo.id = tagId this.currentTagInfo.info = Object.assign({}, info) return info }), ) if (!tagId && page === 1 && sortId === this.sortList[0].id) tasks.push(this.getSongListRecommend()) // 如果是所有类别,则顺便获取推荐列表 return Promise.all(tasks).then(([list, info, recommendList]) => { if (recommendList) list.unshift(...recommendList) return { list, ...info, } }) }, // 获取标签 getTags(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.getInfoUrl()) return this._requestObj_tags.promise.then(({ body }) => { if (body.status !== 1) return this.getTags(++tryNum) return { hotTag: this.filterInfoHotTag(body.data.hotTag), tags: this.filterTagInfo(body.data.tagids), source: 'kg', } }) }, getDetailPageUrl(id) { if (typeof id == 'string') { if (/^https?:\/\//.test(id)) return id id = id.replace('id_', '') } return `https://www.kugou.com/yy/special/single/${id}.html` }, search(text, page, limit = 20) { const params = `userid=1384394652&req_custom=1&appid=1005&req_multi=1&version=11589&page=${page}&filter=0&pagesize=${limit}&order=0&clienttime=1681779443&iscorrection=1&searchsong=0&keyword=${text}&mid=288799920684148686226285199951543865551&dfid=3eSBsO1u97EY1zeIZd40hH4p&clientver=11589&platform=AndroidFilter` const url = encodeURI(`http://complexsearchretry.kugou.com/v1/search/special?${params}&signature=${signatureParams(params, 'android')}`) return createHttpFetch(url).then(body => { // console.log(body) return { list: body.lists.map(item => { return { play_count: formatPlayCount(item.total_play_count), id: item.gid ? `gid_${item.gid}` : `id_${item.specialid}`, author: item.nickname, name: item.specialname, time: dateFormat(item.publish_time, 'Y-M-D'), img: item.img, grade: item.grade, desc: item.intro, total: item.song_count, source: 'kg', } }), limit, total: body.total, source: 'kg', } }) // http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0 // http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5 // http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2 }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/kg/tipSearch.js ================================================ import { createHttpFetch } from './util' export default { requestObj: null, cancelTipSearch() { if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp() }, tipSearchBySong(str) { this.cancelTipSearch() this.requestObj = createHttpFetch(`https://searchtip.kugou.com/getSearchTip?MusicTipCount=10&keyword=${encodeURIComponent(str)}`, { headers: { referer: 'https://www.kugou.com/', }, }) return this.requestObj.then(body => { return body[0].RecordDatas }) }, handleResult(rawData) { return rawData.map(info => info.HintInfo) }, async search(str) { return this.tipSearchBySong(str).then(result => this.handleResult(result)) }, } ================================================ FILE: src/renderer/utils/musicSdk/kg/util.js ================================================ import { toMD5 } from '../utils' import { httpFetch } from '../../request' // s.content[0].lyricContent.forEach(([str]) => { // console.log(str) // }) /** * 签名 * @param {*} params * @param {*} apiver */ export const signatureParams = (params, platform = 'android', body = '') => { let keyparam = 'OIlwieks28dk2k092lksi2UIkp' if (platform === 'web') keyparam = 'NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt' let param_list = params.split('&') param_list.sort() let sign_params = `${keyparam}${param_list.join('')}${body}${keyparam}` return toMD5(sign_params) } /** * 创建一个适用于KG的Http请求 * @param {*} url * @param {*} options * @param {*} retryNum */ export const createHttpFetch = async(url, options, retryNum = 0) => { if (retryNum > 2) throw new Error('try max num') let result try { result = await httpFetch(url, options).promise } catch (err) { console.log(err) return createHttpFetch(url, options, ++retryNum) } // console.log(result.statusCode, result.body) if (result.statusCode !== 200 || ( result.body.error_code ?? result.body.errcode ?? result.body.err_code) != 0 ) return createHttpFetch(url, options, ++retryNum) if (result.body.data) return result.body.data if (Array.isArray(result.body.info)) return result.body return result.body.info } ================================================ FILE: src/renderer/utils/musicSdk/kw/album.js ================================================ import { httpFetch } from '../../request' import { decodeName } from '../../index' import { formatSinger, objStr2JSON } from './util' // let requestObj_list export default { limit_list: 36, limit_song: 1000, filterListDetail(rawList, albumName, albumId) { // console.log(rawList) // console.log(rawList.length, rawList2.length) return rawList.map((item, inedx) => { let formats = item.formats.split('|') let types = [] let _types = {} if (formats.includes('MP3128')) { types.push({ type: '128k', size: null }) _types['128k'] = { size: null, } } // if (formats.includes('MP3192')) { // types.push({ type: '192k', size: null }) // _types['192k'] = { // size: null, // } // } if (formats.includes('MP3H')) { types.push({ type: '320k', size: null }) _types['320k'] = { size: null, } } // if (formats.includes('AL')) { // types.push({ type: 'ape', size: null }) // _types.ape = { // size: null, // } // } if (formats.includes('ALFLAC')) { types.push({ type: 'flac', size: null }) _types.flac = { size: null, } } if (formats.includes('HIRFLAC')) { types.push({ type: 'flac24bit', size: null }) _types.flac24bit = { size: null, } } // types.reverse() return { singer: formatSinger(decodeName(item.artist)), name: decodeName(item.name), albumName, albumId, songmid: item.id, source: 'kw', interval: null, img: item.pic, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, /** * 格式化播放数量 * @param {*} num */ formatPlayCount(num) { if (num > 100000000) return parseInt(num / 10000000) / 10 + '亿' if (num > 10000) return parseInt(num / 1000) / 10 + '万' return num }, getAlbumListDetail(id, page, retryNum = 0) { if (retryNum > 2) return Promise.reject(new Error('try max num')) const requestObj_listDetail = httpFetch(`http://search.kuwo.cn/r.s?pn=${page - 1}&rn=${this.limit_song}&stype=albuminfo&albumid=${id}&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0`) return requestObj_listDetail.promise.then(({ statusCode, body }) => { if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum) body = objStr2JSON(body) // console.log(body) if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum) body.name = decodeName(body.name) return { list: this.filterListDetail(body.musiclist, body.name, body.albumid), page, limit: this.limit_song, total: parseInt(body.songnum), source: 'kw', info: { name: body.name, img: body.img || body.hts_img, desc: decodeName(body.info), author: decodeName(body.artist), // play_count: this.formatPlayCount(body.playnum), }, } }) }, // getAlbumListDetail(id, page, retryNum = 0) { // if (retryNum > 2) return Promise.reject(new Error('try max num')) // return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => { // return resp.promise.then(({ statusCode, body }) => { // console.log(body) // return Promise.reject(new Error('failed')) // // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum) // // const data = body.data // // console.log(data) // // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum) // // return { // // list: this.filterListDetail(data.musiclist), // // page, // // limit: this.limit_song, // // total: data.total, // // source: 'kw', // // info: { // // name: data.album, // // img: data.pic, // // desc: data.albuminfo, // // author: data.artist, // // play_count: this.formatPlayCount(data.playCnt), // // }, // // } // }) // }) // }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/api-temp.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_temp = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://tm.tempmusics.tk/url/kw/${songInfo.songmid}/${type}`, { method: 'get', headers, timeout, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(body.msg)) } }) return requestObj }, } export default api_temp ================================================ FILE: src/renderer/utils/musicSdk/kw/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_test = { // getMusicUrl(songInfo, type) { // const requestObj = httpFetch(`http://45.32.53.128:3002/m/kw/u/${songInfo.songmid}/${type}`, { // method: 'get', // headers, // timeout, // }) // requestObj.promise = requestObj.promise.then(({ body }) => { // return body.code === 0 ? Promise.resolve({ type, url: body.data }) : Promise.reject(new Error(body.msg)) // }) // return requestObj // }, getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/kw/${songInfo.songmid}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, } export default api_test ================================================ FILE: src/renderer/utils/musicSdk/kw/comment.js ================================================ import { httpFetch } from '../../request' import { dateFormat2 } from '../../index' export default { _requestObj: null, _requestObj2: null, async getComment({ songmid }, page = 1, limit = 20) { if (this._requestObj) this._requestObj.cancelHttp() const _requestObj = httpFetch(`http://ncomment.kuwo.cn/com.s?f=web&type=get_comment&aapiver=1&prod=kwplayer_ar_10.5.2.0&digest=15&sid=${songmid}&start=${limit * (page - 1)}&msgflag=1&count=${limit}&newver=3&uid=0`, { headers: { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)', }, }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败') // console.log(body) const total = body.comments_counts return { source: 'kw', comments: this.filterComment(body.comments), total, page, limit, maxPage: Math.ceil(total / limit) || 1, } }, async getHotComment({ songmid }, page = 1, limit = 100) { if (this._requestObj2) this._requestObj2.cancelHttp() const _requestObj2 = httpFetch(`http://ncomment.kuwo.cn/com.s?f=web&type=get_rec_comment&aapiver=1&prod=kwplayer_ar_10.5.2.0&digest=15&sid=${songmid}&start=${limit * (page - 1)}&msgflag=1&count=${limit}&newver=3&uid=0`, { headers: { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)', }, }) const { body, statusCode } = await _requestObj2.promise if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败') // console.log(body) const total = body.hot_comments_counts return { source: 'kw', comments: this.filterComment(body.hot_comments), total, page, limit, maxPage: Math.ceil(total / limit) || 1, } }, filterComment(rawList) { if (!rawList) return [] return rawList.map(item => { return { id: item.id, text: item.msg, time: item.time, timeStr: dateFormat2(Number(item.time) * 1000), userName: item.u_name, avatar: item.u_pic, userId: item.u_id, likedCount: item.like_num, images: item.mpic ? [decodeURIComponent(item.mpic)] : [], reply: item.child_comments ? item.child_comments.map(i => { return { id: i.id, text: i.msg, time: i.time, timeStr: dateFormat2(Number(i.time) * 1000), userName: i.u_name, avatar: i.u_pic, userId: i.u_id, likedCount: i.like_num, images: i.mpic ? [i.mpic] : [], } }) : [], } }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/hotSearch.js ================================================ import { httpFetch } from '../../request' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) const _requestObj = httpFetch('http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1', { headers: { 'User-Agent': 'Dalvik/2.1.0 (Linux; U; Android 9;)', }, }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败') // console.log(body, statusCode) return { source: 'kw', list: this.filterList(body.tagvalue) } }, filterList(rawList) { return rawList.map(item => item.key) }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/index.js ================================================ import { httpFetch } from '../../request' import tipSearch from './tipSearch' import musicSearch from './musicSearch' import { formatSinger } from './util' import leaderboard from './leaderboard' import lyric from './lyric' import pic from './pic' import { apis } from '../api-source' import songList from './songList' import hotSearch from './hotSearch' import comment from './comment' const kw = { _musicInfoRequestObj: null, _musicInfoPromiseCancelFn: null, _musicPicRequestObj: null, _musicPicPromiseCancelFn: null, // context: null, // init(context) { // if (this.isInited) return // this.isInited = true // this.context = context // // this.musicSearch.search('我又想你了').then(res => { // // console.log(res) // // }) // // this.getMusicUrl('62355680', '320k').then(url => { // // console.log(url) // // }) // }, tipSearch, musicSearch, leaderboard, songList, hotSearch, comment, getLyric(songInfo, isGetLyricx) { // let singer = songInfo.singer.indexOf('、') > -1 ? songInfo.singer.split('、')[0] : songInfo.singer return lyric.getLyric(songInfo, isGetLyricx) }, handleMusicInfo(songInfo) { return this.getMusicInfo(songInfo).then(info => { // console.log(JSON.stringify(info)) songInfo.name = info.name songInfo.singer = formatSinger(info.artist) songInfo.img = info.pic songInfo.albumName = info.album return songInfo // return Object.assign({}, songInfo, { // name: info.name, // singer: formatSinger(info.artist), // img: info.pic, // albumName: info.album, // }) }) }, getMusicUrl(songInfo, type) { return apis('kw').getMusicUrl(songInfo, type) }, getMusicInfo(songInfo) { if (this._musicInfoRequestObj) this._musicInfoRequestObj.cancelHttp() this._musicInfoRequestObj = httpFetch(`http://www.kuwo.cn/api/www/music/musicInfo?mid=${songInfo.songmid}`) return this._musicInfoRequestObj.promise.then(({ body }) => { return body.code === 200 ? body.data : Promise.reject(new Error(body.msg)) }) }, getMusicUrls(musicInfo, cb) { let tasks = [] let songId = musicInfo.songmid musicInfo.types.forEach(type => { tasks.push(kw.getMusicUrl(songId, type.type).promise) }) Promise.all(tasks).then(urlInfo => { let typeUrl = {} urlInfo.forEach(info => { typeUrl[info.type] = info.url }) cb(typeUrl) }) }, getPic(songInfo) { return pic.getPic(songInfo) }, getMusicDetailPageUrl(songInfo) { return `http://www.kuwo.cn/play_detail/${songInfo.songmid}` }, // init() { // return getToken() // }, } export default kw ================================================ FILE: src/renderer/utils/musicSdk/kw/leaderboard.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, decodeName } from '../../index' import { formatSinger, wbdCrypto } from './util' const boardList = [{ id: 'kw__93', name: '飙升榜', bangid: '93' }, { id: 'kw__17', name: '新歌榜', bangid: '17' }, { id: 'kw__16', name: '热歌榜', bangid: '16' }, { id: 'kw__158', name: '抖音热歌榜', bangid: '158' }, { id: 'kw__292', name: '铃声榜', bangid: '292' }, { id: 'kw__284', name: '热评榜', bangid: '284' }, { id: 'kw__290', name: 'ACG新歌榜', bangid: '290' }, { id: 'kw__286', name: '台湾KKBOX榜', bangid: '286' }, { id: 'kw__279', name: '冬日暖心榜', bangid: '279' }, { id: 'kw__281', name: '巴士随身听榜', bangid: '281' }, { id: 'kw__255', name: 'KTV点唱榜', bangid: '255' }, { id: 'kw__280', name: '家务进行曲榜', bangid: '280' }, { id: 'kw__282', name: '熬夜修仙榜', bangid: '282' }, { id: 'kw__283', name: '枕边轻音乐榜', bangid: '283' }, { id: 'kw__278', name: '古风音乐榜', bangid: '278' }, { id: 'kw__264', name: 'Vlog音乐榜', bangid: '264' }, { id: 'kw__242', name: '电音榜', bangid: '242' }, { id: 'kw__187', name: '流行趋势榜', bangid: '187' }, { id: 'kw__204', name: '现场音乐榜', bangid: '204' }, { id: 'kw__186', name: 'ACG神曲榜', bangid: '186' }, { id: 'kw__185', name: '最强翻唱榜', bangid: '185' }, { id: 'kw__26', name: '经典怀旧榜', bangid: '26' }, { id: 'kw__104', name: '华语榜', bangid: '104' }, { id: 'kw__182', name: '粤语榜', bangid: '182' }, { id: 'kw__22', name: '欧美榜', bangid: '22' }, { id: 'kw__184', name: '韩语榜', bangid: '184' }, { id: 'kw__183', name: '日语榜', bangid: '183' }, { id: 'kw__145', name: '会员畅听榜', bangid: '145' }, { id: 'kw__153', name: '网红新歌榜', bangid: '153' }, { id: 'kw__64', name: '影视金曲榜', bangid: '64' }, { id: 'kw__176', name: 'DJ嗨歌榜', bangid: '176' }, { id: 'kw__106', name: '真声音', bangid: '106' }, { id: 'kw__12', name: 'Billboard榜', bangid: '12' }, { id: 'kw__49', name: 'iTunes音乐榜', bangid: '49' }, { id: 'kw__180', name: 'beatport电音榜', bangid: '180' }, { id: 'kw__13', name: '英国UK榜', bangid: '13' }, { id: 'kw__164', name: '百大DJ榜', bangid: '164' }, { id: 'kw__246', name: 'YouTube音乐排行榜', bangid: '246' }, { id: 'kw__265', name: '韩国Genie榜', bangid: '265' }, { id: 'kw__14', name: '韩国M-net榜', bangid: '14' }, { id: 'kw__8', name: '香港电台榜', bangid: '8' }, { id: 'kw__15', name: '日本公信榜', bangid: '15' }, { id: 'kw__151', name: '腾讯音乐人原创榜', bangid: '151' }] const sortQualityArray = array => { const qualityMap = { flac24bit: 4, flac: 3, '320k': 2, '128k': 1, } const rawQualityArray = [] const newQualityArray = [] array.forEach((item, index) => { const type = qualityMap[item.type] if (!type) return rawQualityArray.push({ type, index }) }) rawQualityArray.sort((a, b) => a.type - b.type) rawQualityArray.forEach(item => { newQualityArray.push(array[item.index]) }) return newQualityArray } export default { list: [ { id: 'kwbiaosb', name: '飙升榜', bangid: 93, }, { id: 'kwregb', name: '热歌榜', bangid: 16, }, { id: 'kwhuiyb', name: '会员榜', bangid: 145, }, { id: 'kwdouyb', name: '抖音榜', bangid: 158, }, { id: 'kwqsb', name: '趋势榜', bangid: 187, }, { id: 'kwhuaijb', name: '怀旧榜', bangid: 26, }, { id: 'kwhuayb', name: '华语榜', bangid: 104, }, { id: 'kwyueyb', name: '粤语榜', bangid: 182, }, { id: 'kwoumb', name: '欧美榜', bangid: 22, }, { id: 'kwhanyb', name: '韩语榜', bangid: 184, }, { id: 'kwriyb', name: '日语榜', bangid: 183, }, ], // getUrl: (p, l, id) => `http://kbangserver.kuwo.cn/ksong.s?from=pc&fmt=json&pn=${p - 1}&rn=${l}&type=bang&data=content&id=${id}&show_copyright_off=0&pcmp4=1&isbang=1`, regExps: { mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/, }, limit: 100, _requestBoardsObj: null, getBoardsData() { if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp() this._requestBoardsObj = httpFetch('http://qukudata.kuwo.cn/q.k?op=query&cont=tree&node=2&pn=0&rn=1000&fmt=json&level=2') return this._requestBoardsObj.promise }, getData(url) { const requestDataObj = httpFetch(url) return requestDataObj.promise }, filterData(rawList) { return rawList.map(item => { let types = [] const _types = {} const qualitys = new Set() item.n_minfo.split(';').forEach(i => { const info = i.match(this.regExps.mInfo) if (!info) return const quality = info[2] const size = info[4].toLocaleUpperCase() if (qualitys.has(quality)) return qualitys.add(quality) switch (quality) { case '4000': types.push({ type: 'flac24bit', size }) _types.flac24bit = { size } break case '2000': types.push({ type: 'flac', size }) _types.flac = { size } break case '320': types.push({ type: '320k', size }) _types['320k'] = { size } break case '128': types.push({ type: '128k', size }) _types['128k'] = { size } break } }) types = sortQualityArray(types) return { singer: formatSinger(decodeName(item.artist)), name: decodeName(item.name), albumName: decodeName(item.album), albumId: item.albumId, songmid: item.id, source: 'kw', interval: formatPlayTime(parseInt(item.duration)), img: item.pic, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, filterBoardsData(rawList) { // console.log(rawList) let list = [] for (const board of rawList) { if (board.source != '1') continue list.push({ id: 'kw__' + board.sourceid, name: board.name, bangid: String(board.sourceid), }) } return list }, async getBoards(retryNum = 0) { // if (++retryNum > 3) return Promise.reject(new Error('try max num')) // let response // try { // response = await this.getBoardsData() // } catch (error) { // return this.getBoards(retryNum) // } // console.log(response.body) // if (response.statusCode !== 200 || !response.body.child) return this.getBoards(retryNum) // const list = this.filterBoardsData(response.body.child) // // console.log(list) // console.log(JSON.stringify(list)) // this.list = list // return { // list, // source: 'kw', // } this.list = boardList return { list: boardList, source: 'kw', } }, getList(id, page, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) const requestBody = { uid: '', devId: '', sFrom: 'kuwo_sdk', user_type: 'AP', carSource: 'kwplayercar_ar_6.0.1.0_apk_keluze.apk', id, pn: page - 1, rn: this.limit } const requestUrl = `https://wbd.kuwo.cn/api/bd/bang/bang_info?${wbdCrypto.buildParam(requestBody)}` const request = httpFetch(requestUrl).promise return request.then(({ statusCode, body }) => { const rawData = wbdCrypto.decodeData(body) // console.log(rawData) const data = rawData.data if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum) const total = parseInt(data.total) const list = this.filterData(data.musiclist) return { total, list, limit: this.limit, page, source: 'kw', } }) }, // getDetailPageUrl(id) { // return `http://www.kuwo.cn/rankList/${id}` // }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/lyric.js ================================================ import { httpFetch } from '../../request' import { decodeLyric, lrcTools } from './util' import { decodeName } from '../../index' /* export default { formatTime(time) { let m = parseInt(time / 60) let s = (time % 60).toFixed(2) return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s) }, sortLrcArr(arr) { const lrcSet = new Set() let lrc = [] let lrcT = [] for (const item of arr) { if (lrcSet.has(item.time)) { const tItem = lrc.pop() tItem.time = lrc[lrc.length - 1].time lrcT.push(tItem) lrc.push(item) } else { lrc.push(item) lrcSet.add(item.time) } } if (lrcT.length && lrc.length > lrcT.length) { const tItem = lrc.pop() tItem.time = lrc[lrc.length - 1].time lrcT.push(tItem) } return { lrc, lrcT, } }, transformLrc(songinfo, lrclist) { return `[ti:${songinfo.songName}]\n[ar:${songinfo.artist}]\n[al:${songinfo.album}]\n[by:]\n[offset:0]\n${lrclist ? lrclist.map(l => `[${this.formatTime(l.time)}]${l.lineLyric}\n`).join('') : '暂无歌词'}` }, getLyric(songId) { const requestObj = httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${songId}`) requestObj.promise = requestObj.promise.then(({ body }) => { // console.log(body) if (!body.data?.lrclist?.length) return Promise.reject(new Error('Get lyric failed')) let lrcInfo try { lrcInfo = this.sortLrcArr(body.data.lrclist) } catch { return Promise.reject(new Error('Get lyric failed')) } // console.log(body.data.lrclist) // console.log(lrcInfo.lrc, lrcInfo.lrcT) // console.log({ // lyric: decodeName(this.transformLrc(body.data.songinfo, lrc)), // tlyric: decodeName(this.transformLrc(body.data.songinfo, lrcT)), // }) return { lyric: decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrc)), tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrcT)) : '', } }) return requestObj }, } */ const buf_key = Buffer.from('yeelion') const buf_key_len = buf_key.length const buildParams = (id, isGetLyricx) => { let params = `user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_${id}` if (isGetLyricx) params += '&lrcx=1' const buf_str = Buffer.from(params) const buf_str_len = buf_str.length const output = new Uint16Array(buf_str_len) let i = 0 while (i < buf_str_len) { let j = 0 while (j < buf_key_len && i < buf_str_len) { output[i] = buf_key[j] ^ buf_str[i] i++ j++ } } return Buffer.from(output).toString('base64') } // console.log(buildParams('207527604', false)) // console.log(buildParams('207527604', true)) const timeExp = /^\[([\d:.]*)\]{1}/g const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/ const lyricxTag = /^<-?\d+,-?\d+>/ export default { /* sortLrcArr(arr) { const lrcSet = new Set() let lrc = [] let lrcT = [] let markIndex = [] for (const item of arr) { if (lrcSet.has(item.time)) { if (lrc.length < 2) continue const index = lrc.findIndex(l => l.time == item.time) markIndex.push(index) if (index == lrc.length - 1) { lrcT.push({ ...lrc[index], time: item.time }) lrc.push(item) } else { lrcT.push({ ...lrc[index], time: lrc[index + 1].time }) if (item.text) { // const lastIndex = lrc.length - 1 // markIndex.push(lastIndex) // lrcT.push({ ...lrc[lastIndex], time: lrc[lastIndex - 1].time }) lrc.push(item) } } } else { lrc.push(item) lrcSet.add(item.time) } } // console.log(markIndex) markIndex = Array.from(new Set(markIndex)) for (let index = markIndex.length - 1; index >= 0; index--) { lrc.splice(markIndex[index], 1) } // if (lrcT.length) { // if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译 // const tItem = lrc.pop() // tItem.time = lrc[lrc.length - 1].time // lrcT.push(tItem) // } else { // lrc = arr // lrcT = [] // } // } console.log(lrc, lrcT) return { lrc, lrcT, } }, */ sortLrcArr(arr) { const lrcSet = new Set() let lrc = [] let lrcT = [] let isLyricx = false for (const item of arr) { if (lrcSet.has(item.time)) { if (lrc.length < 2) continue const tItem = lrc.pop() tItem.time = lrc[lrc.length - 1].time lrcT.push(tItem) lrc.push(item) } else { lrc.push(item) lrcSet.add(item.time) } if (!isLyricx && lyricxTag.test(item.text)) isLyricx = true } if (!isLyricx && lrcT.length > lrc.length * 0.3 && lrc.length - lrcT.length > 6) { throw new Error('failed') // if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译 // const tItem = lrc.pop() // tItem.time = lrc[lrc.length - 1].time // lrcT.push(tItem) // } else { // lrc = arr // lrcT = [] // } } return { lrc, lrcT, } }, transformLrc(tags, lrclist) { return `${tags.join('\n')}\n${lrclist ? lrclist.map(l => `[${l.time}]${l.text}\n`).join('') : '暂无歌词'}` }, parseLrc(lrc) { const lines = lrc.split(/\r\n|\r|\n/) let tags = [] let lrcArr = [] for (let i = 0; i < lines.length; i++) { const line = lines[i].trim() let result = timeExp.exec(line) if (result) { const text = line.replace(timeExp, '').trim() let time = RegExp.$1 if (/\.\d\d$/.test(time)) time += '0' lrcArr.push({ time, text, }) } else if (lrcTools.rxps.tagLine.test(line)) { tags.push(line) } } const lrcInfo = this.sortLrcArr(lrcArr) return { lyric: decodeName(this.transformLrc(tags, lrcInfo.lrc)), tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '', } }, // getLyric2(musicInfo, isGetLyricx = true) { // const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`) // requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => { // if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body))) // return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => { // let lrcInfo // console.log(Buffer.from(base64Data, 'base64').toString()) // try { // lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString()) // } catch { // return Promise.reject(new Error('Get lyric failed')) // } // if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') // lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric) // // console.log(lrcInfo.lyric) // // console.log(lrcInfo.tlyric) // // console.log(lrcInfo.lxlyric) // // console.log(JSON.stringify(lrcInfo)) // }) // }) // return requestObj // }, getLyric(musicInfo, isGetLyricx = true) { // this.getLyric2(musicInfo) const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`) requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => { if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body))) return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => { // let lrcInfo // try { // lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString()) // } catch { // return Promise.reject(new Error('Get lyric failed')) // } let lrcInfo // console.log(Buffer.from(base64Data, 'base64').toString()) try { lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString()) } catch (err) { return Promise.reject(new Error('Get lyric failed')) } // console.log(lrcInfo) if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') try { lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric) } catch { lrcInfo.lxlyric = '' } lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '') if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed')) // console.log(lrcInfo) return lrcInfo }) }) return requestObj }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/musicSearch.js ================================================ // import '../../polyfill/array.find' import { httpFetch } from '../../request' import { formatPlayTime, decodeName } from '../../index' // import { debug } from '../../utils/env' import { formatSinger } from './util' export default { regExps: { mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/, }, limit: 30, total: 0, page: 0, allPage: 1, // cancelFn: null, musicSearch(str, page, limit) { const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`) return musicSearchRequestObj.promise }, // getImg(songId) { // return httpGet(`http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_${songId}`) // }, // getLrc(songId) { // return httpGet(`http://mobile.kuwo.cn/mpage/html5/songinfoandlrc?mid=${songId}&flag=0`) // }, handleResult(rawData) { const result = [] if (!rawData) return result // console.log(rawData) for (let i = 0; i < rawData.length; i++) { const info = rawData[i] let songId = info.MUSICRID.replace('MUSIC_', '') // const format = (info.FORMATS || info.formats).split('|') if (!info.N_MINFO) { console.log('N_MINFO is undefined') return null } const types = [] const _types = {} let infoArr = info.N_MINFO.split(';') for (let info of infoArr) { info = info.match(this.regExps.mInfo) if (info) { switch (info[2]) { case '4000': types.push({ type: 'flac24bit', size: info[4] }) _types.flac24bit = { size: info[4].toLocaleUpperCase(), } break case '2000': types.push({ type: 'flac', size: info[4] }) _types.flac = { size: info[4].toLocaleUpperCase(), } break case '320': types.push({ type: '320k', size: info[4] }) _types['320k'] = { size: info[4].toLocaleUpperCase(), } break case '128': types.push({ type: '128k', size: info[4] }) _types['128k'] = { size: info[4].toLocaleUpperCase(), } break } } } types.reverse() let interval = parseInt(info.DURATION) result.push({ name: decodeName(info.SONGNAME), singer: formatSinger(decodeName(info.ARTIST)), source: 'kw', // img = (info.album.name === '' || info.album.name === '空') // ? `http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_160911.jpg` // : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${info.album.mid}.jpg` songmid: songId, albumId: decodeName(info.ALBUMID || ''), interval: Number.isNaN(interval) ? 0 : formatPlayTime(interval), albumName: info.ALBUM ? decodeName(info.ALBUM) : '', lrc: null, img: null, otherSource: null, types, _types, typeUrl: {}, }) } // console.log(result) return result }, search(str, page = 1, limit, retryNum = 0) { if (retryNum > 2) return Promise.reject(new Error('try max num')) if (limit == null) limit = this.limit // http://newlyric.kuwo.cn/newlyric.lrc?62355680 return this.musicSearch(str, page, limit).then(({ body: result }) => { // console.log(result) if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum) let list = this.handleResult(result.abslist) if (list == null) return this.search(str, page, limit, ++retryNum) this.total = parseInt(result.TOTAL) this.page = page this.allPage = Math.ceil(this.total / limit) return Promise.resolve({ list, allPage: this.allPage, total: this.total, limit, source: 'kw', }) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/pic.js ================================================ import { httpFetch } from '../../request' export default { getPic({ songmid }) { const requestObj = httpFetch(`http://artistpicserver.kuwo.cn/pic.web?corp=kuwo&type=rid_pic&pictype=500&size=500&rid=${songmid}`) requestObj.promise = requestObj.promise.then(({ body }) => /^http/.test(body) ? body : null) return requestObj.promise }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/songList.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, decodeName } from '../../index' import { formatSinger, objStr2JSON } from './util' import album from './album' export default { _requestObj_tags: null, _requestObj_hotTags: null, _requestObj_list: null, limit_list: 36, limit_song: 1000, successCode: 200, sortList: [ { name: '最新', id: 'new', }, { name: '最热', id: 'hot', }, ], regExps: { mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/, // http://www.kuwo.cn/playlist_detail/2886046289 // https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/, }, tagsUrl: 'http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0&loginUid=0&loginSid=0&appUid=76039576', hotTagUrl: 'http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576', getListUrl({ sortId, id, type, page }) { if (!id) return `http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0&appUid=76039576&&pn=${page}&rn=${this.limit_list}&order=${sortId}` switch (type) { case '10000': return `http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0&appUid=76039576&pn=${page}&id=${id}&rn=${this.limit_list}` case '43': return `http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=${id}&prod=pc` } // http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0&appUid=76039576&id=173&pn=1&rn=100 }, getListDetailUrl(id, page) { // http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2858093057&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1 return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1` // http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=140&prod=pc }, // http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2849349915&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1 // 获取标签 getTag(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.tagsUrl) return this._requestObj_tags.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getTag(++tryNum) return this.filterTagInfo(body.data) }) }, // 获取标签 getHotTag(tryNum = 0) { if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_hotTags = httpFetch(this.hotTagUrl) return this._requestObj_hotTags.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getHotTag(++tryNum) return this.filterInfoHotTag(body.data[0].data) }) }, filterInfoHotTag(rawList) { return rawList.map(item => ({ id: `${item.id}-${item.digest}`, name: item.name, source: 'kw', })) }, filterTagInfo(rawList) { return rawList.map(type => ({ name: type.name, list: type.data.map(item => ({ parent_id: type.id, parent_name: type.name, id: `${item.id}-${item.digest}`, name: item.name, source: 'kw', })), })) }, // 获取列表数据 getList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) let id let type if (tagId) { let arr = tagId.split('-') id = arr[0] type = arr[1] } else { id = null } this._requestObj_list = httpFetch(this.getListUrl({ sortId, id, type, page })) return this._requestObj_list.promise.then(({ body }) => { if (!id || type == '10000') { if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) return { list: this.filterList(body.data.data), total: body.data.total, page: body.data.pn, limit: body.data.rn, source: 'kw', } } else if (!body.length) { return this.getList(sortId, tagId, page, ++tryNum) } return { list: this.filterList2(body), total: 1000, page, limit: 1000, source: 'kw', } }) }, /** * 格式化播放数量 * @param {*} num */ formatPlayCount(num) { if (num > 100000000) return parseInt(num / 10000000) / 10 + '亿' if (num > 10000) return parseInt(num / 1000) / 10 + '万' return num }, filterList(rawData) { return rawData.map(item => ({ play_count: this.formatPlayCount(item.listencnt), id: `digest-${item.digest}__${item.id}`, author: item.uname, name: item.name, // time: item.publish_time, total: item.total, img: item.img, grade: item.favorcnt / 10, desc: item.desc, source: 'kw', })) }, filterList2(rawData) { // console.log(rawData) const list = [] rawData.forEach(item => { if (!item.label) return list.push(...item.list.map(item => ({ play_count: item.play_count && this.formatPlayCount(item.listencnt), id: `digest-${item.digest}__${item.id}`, author: item.uname, name: item.name, total: item.total, // time: item.publish_time, img: item.img, grade: item.favorcnt && item.favorcnt / 10, desc: item.desc, source: 'kw', }))) }) return list }, getListDetailDigest8(id, page, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) const requestObj = httpFetch(this.getListDetailUrl(id, page)) return requestObj.promise.then(({ body }) => { if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum) return { list: this.filterListDetail(body.musiclist), page, limit: body.rn, total: body.total, source: 'kw', info: { name: body.title, img: body.pic, desc: body.info, author: body.uname, play_count: this.formatPlayCount(body.playnum), }, } }) }, getListDetailDigest5Info(id, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) const requestObj = httpFetch(`http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node=${id}&pn=0&rn=1&fmt=json&src=mbox&level=2`) return requestObj.promise.then(({ statusCode, body }) => { if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum) // console.log(body) return body.child.length ? body.child[0].sourceid : null }) }, getListDetailDigest5Music(id, page, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`) return requestObj.promise.then(({ body }) => { // console.log(body) if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum) return { list: this.filterListDetail(body.musiclist), page, limit: body.rn, total: body.total, source: 'kw', info: { name: body.title, img: body.pic, desc: body.info, author: body.uname, play_count: this.formatPlayCount(body.playnum), }, } }) }, async getListDetailDigest5(id, page, retryNum) { const detailId = await this.getListDetailDigest5Info(id, retryNum) return this.getListDetailDigest5Music(detailId, page, retryNum) }, filterBDListDetail(rawList) { return rawList.map(item => { let types = [] let _types = {} for (let info of item.audios) { info.size = info.size?.toLocaleUpperCase() switch (info.bitrate) { case '4000': types.push({ type: 'flac24bit', size: info.size }) _types.flac24bit = { size: info.size, } break case '2000': types.push({ type: 'flac', size: info.size }) _types.flac = { size: info.size, } break case '320': types.push({ type: '320k', size: info.size }) _types['320k'] = { size: info.size, } break case '128': types.push({ type: '128k', size: info.size }) _types['128k'] = { size: info.size, } break } } types.reverse() return { singer: item.artists.map(s => s.name).join('、'), name: item.name, albumName: item.album, albumId: item.albumId, songmid: item.id, source: 'kw', interval: formatPlayTime(item.duration), img: item.albumPic, releaseDate: item.releaseDate, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, getReqId() { function t() { return (65536 * (1 + Math.random()) | 0).toString(16).substring(1) } return t() + t() + t() + t() + t() + t() + t() + t() }, async getListDetailMusicListByBDListInfo(id, source) { const { body: infoData } = await httpFetch(`https://bd-api.kuwo.cn/api/service/playlist/info/${id}?reqId=${this.getReqId()}&source=${source}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', plat: 'h5', }, }).promise.catch(() => ({ code: 0 })) if (infoData.code != 200) return null return { name: infoData.data.name, img: infoData.data.pic, desc: infoData.data.description, author: infoData.data.creatorName, play_count: infoData.data.playNum, } }, async getListDetailMusicListByBDUserPub(id) { const { body: infoData } = await httpFetch(`https://bd-api.kuwo.cn/api/ucenter/users/pub/${id}?reqId=${this.getReqId()}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', plat: 'h5', }, }).promise.catch(() => ({ code: 0 })) if (infoData.code != 200) return null // console.log(infoData) return { name: infoData.data.userInfo.nickname + '喜欢的音乐', img: infoData.data.userInfo.headImg, desc: '', author: infoData.data.userInfo.nickname, play_count: '', } }, async getListDetailMusicListByBDList(id, source, page, tryNum = 0) { const { body: listData } = await httpFetch(`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${this.limit_song}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', plat: 'h5', }, }).promise.catch(() => { if (tryNum > 2) return Promise.reject(new Error('try max num')) return this.getListDetailMusicListByBDList(id, source, page, ++tryNum) }) if (listData.code !== 200) return Promise.reject(new Error('failed')) return { list: this.filterBDListDetail(listData.data.list), page, limit: listData.data.pageSize, total: listData.data.total, source: 'kw', } }, async getListDetailMusicListByBD(id, page) { const uid = /uid=(\d+)/.exec(id)?.[1] const listId = /playlistId=(\d+)/.exec(id)?.[1] const source = /source=(\d+)/.exec(id)?.[1] if (!listId) return Promise.reject(new Error('failed')) const task = [this.getListDetailMusicListByBDList(listId, source, page)] switch (source) { case '4': task.push(this.getListDetailMusicListByBDListInfo(listId, source)) break case '5': task.push(this.getListDetailMusicListByBDUserPub(uid ?? listId)) break } const [listData, info] = await Promise.all(task) listData.info = info ?? { name: '', img: '', desc: '', author: '', play_count: '', } // console.log(listData) return listData }, // 获取歌曲列表内的音乐 getListDetail(id, page, retryNum = 0) { // console.log(id) // https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000 // https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId= if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page) if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1') else if (/^digest-/.test(id)) { let [digest, _id] = id.split('__') digest = digest.replace('digest-', '') id = _id switch (digest) { case '8': break case '13': return album.getAlbumListDetail(id, page, retryNum) case '5': default: return this.getListDetailDigest5(id, page, retryNum) } } return this.getListDetailDigest8(id, page, retryNum) }, filterListDetail(rawData) { // console.log(rawData) return rawData.map(item => { let infoArr = item.N_MINFO.split(';') let types = [] let _types = {} for (let info of infoArr) { info = info.match(this.regExps.mInfo) if (info) { switch (info[2]) { case '4000': types.push({ type: 'flac24bit', size: info[4] }) _types.flac24bit = { size: info[4].toLocaleUpperCase(), } break case '2000': types.push({ type: 'flac', size: info[4] }) _types.flac = { size: info[4].toLocaleUpperCase(), } break case '320': types.push({ type: '320k', size: info[4] }) _types['320k'] = { size: info[4].toLocaleUpperCase(), } break case '128': types.push({ type: '128k', size: info[4] }) _types['128k'] = { size: info[4].toLocaleUpperCase(), } break } } } types.reverse() return { singer: formatSinger(decodeName(item.artist)), name: decodeName(item.name), albumName: decodeName(item.album), albumId: item.albumid, songmid: item.id, source: 'kw', interval: formatPlayTime(parseInt(item.duration)), img: null, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, getTags() { return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({ tags, hotTag, source: 'kw' })) }, getDetailPageUrl(id) { if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1') else if (/^digest-/.test(id)) { let result = id.split('__') id = result[1] } return `http://www.kuwo.cn/playlist_detail/${id}` }, search(text, page, limit = 20) { return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`) .promise.then(({ body }) => { body = objStr2JSON(body) // console.log(body) return { list: body.abslist.map(item => { return { play_count: this.formatPlayCount(item.playcnt), id: String(item.playlistid), author: decodeName(item.nickname), name: decodeName(item.name), total: item.songnum, // time: item.publish_time, img: item.pic, desc: decodeName(item.intro), source: 'kw', } }), limit, total: parseInt(body.TOTAL), source: 'kw', } }) }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/kw/tipSearch.js ================================================ // import { decodeName } from '../../index' // import { tokenRequest } from './util' import { httpFetch } from '../../request' export default { regExps: { relWord: /RELWORD=(.+)/, }, requestObj: null, async tipSearchBySong(str) { // 报错403,加了referer还是有问题(直接换一个 // this.requestObj = await tokenRequest(`http://www.kuwo.cn/api/www/search/searchKey?key=${encodeURIComponent(str)}`) this.cancelTipSearch() this.requestObj = httpFetch(`https://tips.kuwo.cn/t.s?corp=kuwo&newver=3&p2p=1¬race=0&c=mbox&w=${encodeURIComponent(str)}&encoding=utf8&rformat=json`, { Referer: 'http://www.kuwo.cn/', }) return this.requestObj.promise.then(({ body, statusCode }) => { if (statusCode != 200 || !body.WORDITEMS) return Promise.reject(new Error('请求失败')) return body.WORDITEMS }) }, handleResult(rawData) { return rawData.map(item => item.RELWORD) }, cancelTipSearch() { if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp() }, async search(str) { return this.tipSearchBySong(str).then(result => this.handleResult(result)) }, } ================================================ FILE: src/renderer/utils/musicSdk/kw/util.js ================================================ // import { httpGet, httpFetch } from '../../request' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' import { rendererInvoke } from '@common/rendererIpc' import { createCipheriv, createDecipheriv } from 'crypto' import { toMD5 } from '../utils' // const kw_token = { // token: null, // isGetingToken: false, // } // const translationMap = { // "{'": '{"', // "'}\n": '"}', // "'}": '"}', // "':'": '":"', // "','": '","', // "':{'": '":{"', // "':['": '":["', // "'}],'": '"}],"', // "':[{'": '":[{"', // "'},'": '"},"', // "'},{'": '"},{"', // "':[],'": '":[],"', // "':{},'": '":{},"', // "'}]}": '"}]}', // } // export const objStr2JSON = str => { // return JSON.parse(str.replace(/(^{'|'}\n$|'}$|':'|','|':\[{'|'}\],'|':{'|'},'|'},{'|':\['|':\[\],'|':{},'|'}]})/g, s => translationMap[s])) // } export const objStr2JSON = str => { return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"')) } export const formatSinger = rawData => rawData.replace(/&/g, '、') export const matchToken = headers => { try { return headers['set-cookie'][0].match(/kw_token=(\w+)/)[1] } catch (err) { return null } } // const wait = time => new Promise(resolve => setTimeout(() => resolve(), time)) // export const getToken = (retryNum = 0) => new Promise((resolve, reject) => { // if (retryNum > 2) return Promise.reject(new Error('try max num')) // if (kw_token.isGetingToken) return wait(1000).then(() => getToken(retryNum).then(token => resolve(token))) // if (kw_token.token) return resolve(kw_token.token) // kw_token.isGetingToken = true // httpGet('http://www.kuwo.cn/', (err, resp) => { // kw_token.isGetingToken = false // if (err) return getToken(++retryNum) // if (resp.statusCode != 200) return reject(new Error('获取失败')) // const token = kw_token.token = matchToken(resp.headers) // resolve(token) // }) // }) export const decodeLyric = base64Data => rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_kw_decode_lyric, base64Data) // export const tokenRequest = async(url, options = {}) => { // let token = kw_token.token // if (!token) token = await getToken() // if (!options.headers) { // options.headers = { // Referer: 'http://www.kuwo.cn/', // csrf: token, // cookie: 'kw_token=' + token, // } // } // const requestObj = httpFetch(url, options) // requestObj.promise = requestObj.promise.then(resp => { // // console.log(resp) // if (resp.statusCode == 200) { // kw_token.token = matchToken(resp.headers) // } // return resp // }) // return requestObj // } export const lrcTools = { rxps: { wordLine: /^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*/, tagLine: /\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]/, wordTimeAll: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/g, wordTime: /<(-?\d+),(-?\d+)(?:,-?\d+)?>/, }, offset: 1, offset2: 1, isOK: false, lines: [], tags: [], getWordInfo(str, str2, prevWord) { const offset = parseInt(str) const offset2 = parseInt(str2) let startTime = Math.abs((offset + offset2) / (this.offset * 2)) let endTime = Math.abs((offset - offset2) / (this.offset2 * 2)) + startTime if (prevWord) { if (startTime < prevWord.endTime) { prevWord.endTime = startTime if (prevWord.startTime > prevWord.endTime) { prevWord.startTime = prevWord.endTime } prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>` // console.log(prevWord) } } return { startTime, endTime, timeStr: `<${startTime},${endTime - startTime}>`, } }, parseLine(line) { if (line.length < 6) return let result = this.rxps.wordLine.exec(line) if (result) { const time = result[1] let words = result[2] if (words == null) { words = '' } const wordTimes = words.match(this.rxps.wordTimeAll) if (!wordTimes) return // console.log(wordTimes) let preTimeInfo for (const timeStr of wordTimes) { const result = this.rxps.wordTime.exec(timeStr) const wordInfo = this.getWordInfo(result[1], result[2], preTimeInfo) words = words.replace(timeStr, wordInfo.timeStr) if (preTimeInfo?.newTimeStr) words = words.replace(preTimeInfo.timeStr, preTimeInfo.newTimeStr) preTimeInfo = wordInfo } this.lines.push(time + words) return } result = this.rxps.tagLine.exec(line) if (!result) return if (result[1] == 'kuwo') { let content = result[2] if (content != null && content.includes('][')) { content = content.substring(0, content.indexOf('][')) } const valueOf = parseInt(content, 8) this.offset = Math.trunc(valueOf / 10) this.offset2 = Math.trunc(valueOf % 10) if (this.offset == 0 || Number.isNaN(this.offset) || this.offset2 == 0 || Number.isNaN(this.offset2)) { this.isOK = false } } else { this.tags.push(line) } }, parse(lrc) { // console.log(lrc) const lines = lrc.split(/\r\n|\r|\n/) const tools = Object.create(this) tools.isOK = true tools.offset = 1 tools.offset2 = 1 tools.lines = [] tools.tags = [] for (const line of lines) { if (!tools.isOK) throw new Error('failed') tools.parseLine(line) } if (!tools.lines.length) return '' let lrcs = tools.lines.join('\n') if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}` // console.log(lrcs) return lrcs }, } const createAesEncrypt = (buffer, mode, key, iv) => { const cipher = createCipheriv(mode, key, iv) return Buffer.concat([cipher.update(buffer), cipher.final()]) } const createAesDecrypt = (buffer, mode, key, iv) => { const cipher = createDecipheriv(mode, key, iv) return Buffer.concat([cipher.update(buffer), cipher.final()]) } export const wbdCrypto = { aesMode: 'aes-128-ecb', aesKey: Buffer.from([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228], 'binary'), aesIv: '', appId: 'y67sprxhhpws', decodeData(base64Result) { const data = Buffer.from(decodeURIComponent(base64Result), 'base64') return JSON.parse(createAesDecrypt(data, this.aesMode, this.aesKey, this.aesIv).toString()) }, createSign(data, time) { const str = `${this.appId}${data}${time}` return toMD5(str).toUpperCase() }, buildParam(jsonData) { const data = Buffer.from(JSON.stringify(jsonData)) const time = Date.now() const encodeData = createAesEncrypt(data, this.aesMode, this.aesKey, this.aesIv).toString('base64') const sign = this.createSign(encodeData, time) return `data=${encodeURIComponent(encodeData)}&time=${time}&appId=${this.appId}&sign=${sign}` }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/album.js ================================================ import { createHttpFetch } from './utils' import { filterMusicInfoList } from './musicInfo' import { formatPlayCount } from '../../index' export default { /** * 通过AlbumId获取专辑 * @param {*} id * @param {*} page */ async getAlbumDetail(id, page = 1) { const list = await createHttpFetch(`http://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong?albumId=${id}&pageNo=${page}`) if (!list.songList) return Promise.reject(new Error('Get album list error.')) const songList = filterMusicInfoList(list.songList) const listInfo = await this.getAlbumInfo(id) return { list: songList || [], page, limit: listInfo.total, total: listInfo.total, source: 'mg', info: { name: listInfo.name, img: listInfo.image, desc: listInfo.desc, author: listInfo.author, play_count: listInfo.play_count, }, } }, /** * 通过AlbumId获取专辑信息 * @param {*} id * @param {*} page */ async getAlbumInfo(id) { const info = await createHttpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=${id}`) if (!info) return Promise.reject(new Error('Get album info error.')) return { name: info.title, image: info.imgItems.length ? info.imgItems[0].img : null, desc: info.summary, author: info.singer, play_count: formatPlayCount(info.opNumItem.playNum), total: info.totalCount, } }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_test = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/mg/${songInfo.copyrightId}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, } export default api_test ================================================ FILE: src/renderer/utils/musicSdk/mg/comment.js ================================================ import { httpFetch } from '../../request' import getSongId from './songId' import { dateFormat2 } from '../../index' export default { _requestObj: null, _requestObj2: null, _requestObj3: null, lastCommentIds: new Map(), async getComment(musicInfo, page = 1, limit = 20) { if (this._requestObj) this._requestObj.cancelHttp() if (!musicInfo.songId) { let id = await getSongId(musicInfo) if (!id) throw new Error('获取评论失败') musicInfo.songId = id } if (page === 1) this.lastCommentIds.clear() const lastCommentId = this.lastCommentIds.get(String(page)) || '' if (!lastCommentId && page > 1) throw new Error('获取评论失败') // const _requestObj = httpFetch(`https://music.migu.cn/v3/api/comment/listComments?targetId=${musicInfo.songId}&pageSize=${limit}&pageNo=${page}`, { const _requestObj = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/v1.0?pageSize=${limit}&queryType=1&resourceId=${musicInfo.songId}&resourceType=2&commentId=${lastCommentId}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', // Referer: 'https://music.migu.cn', }, }) const { body, statusCode } = await _requestObj.promise // console.log(body) if (statusCode != 200 || body.code !== '000000') throw new Error('获取评论失败') const total = parseInt(body.data.commentNums) const list = this.filterComment(body.data.comments) this.lastCommentIds.set(String(page + 1), list.length ? list[list.length - 1].id : '') return { source: 'mg', comments: list, total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, async getHotComment(musicInfo, page = 1, limit = 20) { if (this._requestObj2) this._requestObj2.cancelHttp() if (!musicInfo.songId) { let id = await getSongId(musicInfo) if (!id) throw new Error('获取评论失败') musicInfo.songId = id } // const _requestObj2 = httpFetch(`https://music.migu.cn/v3/api/comment/listTopComments?targetId=${musicInfo.songId}&pageSize=${limit}&pageNo=${page}`, { const _requestObj2 = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/v1.0?pageSize=${limit}&queryType=2&resourceId=${musicInfo.songId}&resourceType=2&hotCommentStart=${(page - 1) * limit}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', // Referer: 'https://music.migu.cn', }, }) const { body, statusCode } = await _requestObj2.promise // console.log(body) if (statusCode != 200 || body.code !== '000000') throw new Error('获取热门评论失败') const total = parseInt(body.data.cfgHotCount) return { source: 'mg', comments: this.filterComment(body.data.hotComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, async getReplyComment(musicInfo, replyId, page = 1, limit = 10) { if (this._requestObj2) this._requestObj2.cancelHttp() // const _requestObj2 = httpFetch(`https://music.migu.cn/v3/api/comment/listCommentsById?commentId=${replyId}&pageSize=${limit}&pageNo=${page}`, { const _requestObj2 = httpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/user/comment/stack/${replyId}/v1.0?pageSize=${limit}&queryType=2&resourceId=${musicInfo.songId}&resourceType=2&start=${(page - 1) * limit}`, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', }, }) const { body, statusCode } = await _requestObj2.promise // console.log(body) if (statusCode != 200 || body.code !== '000000') throw new Error('获取回复评论失败') const total = parseInt(body.data.replyTotalCount) return { source: 'mg', comments: this.filterComment(body.data.mainCommentItem.replyComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, filterComment(rawList) { return rawList.map(item => ({ id: item.commentId, text: item.commentInfo, time: item.commentTime, timeStr: dateFormat2(new Date(item.commentTime).getTime()), userName: item.user.nickName, avatar: item.user.middleIcon || item.user.bigIcon || item.user.smallIcon, userId: item.user.userId, likedCount: item.opNumItem.thumbNum, replyNum: item.replyTotalCount, reply: item.replyComments.map(c => ({ id: c.replyId, text: c.replyInfo, time: c.replyTime, timeStr: dateFormat2(new Date(c.replyTime).getTime()), userName: c.user.nickName, avatar: c.user.middleIcon || c.user.bigIcon || c.user.smallIcon, userId: c.user.userId, likedCount: null, replyNum: null, })), })) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/hotSearch.js ================================================ import { httpFetch } from '../../request' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) const _requestObj = httpFetch('http://jadeite.migu.cn:7090/music_search/v3/search/hotword') const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.code !== '000000') throw new Error('获取热搜词失败') // console.log(body, statusCode) return { source: 'mg', list: this.filterList(body.data.hotwords[0].hotwordList) } }, filterList(rawList) { return rawList.filter(item => item.resourceType == 'song').map(item => item.word) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/index.js ================================================ import { apis } from '../api-source' import leaderboard from './leaderboard' import songList from './songList' import musicSearch from './musicSearch' import pic from './pic' import lyric from './lyric' import hotSearch from './hotSearch' import comment from './comment' // import tipSearch from './tipSearch' const mg = { // tipSearch, songList, musicSearch, leaderboard, hotSearch, comment, getMusicUrl(songInfo, type) { return apis('mg').getMusicUrl(songInfo, type) }, getLyric(songInfo) { return lyric.getLyric(songInfo) }, getPic(songInfo) { return pic.getPic(songInfo) }, getMusicDetailPageUrl(songInfo) { return `http://music.migu.cn/v3/music/song/${songInfo.copyrightId}` }, } export default mg ================================================ FILE: src/renderer/utils/musicSdk/mg/leaderboard.js ================================================ import { httpFetch } from '../../request' import { filterMusicInfoList } from './musicInfo' // const boardList = [{ id: 'mg__27553319', name: '咪咕尖叫新歌榜', bangid: '27553319' }, { id: 'mg__27186466', name: '咪咕尖叫热歌榜', bangid: '27186466' }, { id: 'mg__27553408', name: '咪咕尖叫原创榜', bangid: '27553408' }, { id: 'mg__23189800', name: '咪咕港台榜', bangid: '23189800' }, { id: 'mg__23189399', name: '咪咕内地榜', bangid: '23189399' }, { id: 'mg__19190036', name: '咪咕欧美榜', bangid: '19190036' }, { id: 'mg__23189813', name: '咪咕日韩榜', bangid: '23189813' }, { id: 'mg__23190126', name: '咪咕彩铃榜', bangid: '23190126' }, { id: 'mg__15140045', name: '咪咕KTV榜', bangid: '15140045' }, { id: 'mg__15140034', name: '咪咕网络榜', bangid: '15140034' }, { id: 'mg__23217754', name: 'MV榜', bangid: '23217754' }, { id: 'mg__23218151', name: '新专辑榜', bangid: '23218151' }, { id: 'mg__21958042', name: 'iTunes榜', bangid: '21958042' }, { id: 'mg__21975570', name: 'billboard榜', bangid: '21975570' }, { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815' }, { id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' }, { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943' }, { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437' }] // const boardList = [ // { id: 'mg__27553319', name: '尖叫新歌榜', bangid: '27553319', webId: 'jianjiao_newsong' }, // { id: 'mg__27186466', name: '尖叫热歌榜', bangid: '27186466', webId: 'jianjiao_hotsong' }, // { id: 'mg__27553408', name: '尖叫原创榜', bangid: '27553408', webId: 'jianjiao_original' }, // { id: 'mg__23189800', name: '港台榜', bangid: '23189800', webId: 'hktw' }, // { id: 'mg__23189399', name: '内地榜', bangid: '23189399', webId: 'mainland' }, // { id: 'mg__19190036', name: '欧美榜', bangid: '19190036', webId: 'eur_usa' }, // { id: 'mg__23189813', name: '日韩榜', bangid: '23189813', webId: 'jpn_kor' }, // { id: 'mg__23190126', name: '彩铃榜', bangid: '23190126', webId: 'coloring' }, // { id: 'mg__15140045', name: 'KTV榜', bangid: '15140045', webId: 'ktv' }, // { id: 'mg__15140034', name: '网络榜', bangid: '15140034', webId: 'network' }, // // { id: 'mg__21958042', name: '美国iTunes榜', bangid: '21958042', webId: 'itunes' }, // // { id: 'mg__21975570', name: '美国billboard榜', bangid: '21975570', webId: 'billboard' }, // // { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815', webId: 'hito' }, // // { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943', webId: 'mnet' }, // // { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437', webId: 'uk' }, // ] const boardList = [ { id: 'mg__27553319', name: '新歌榜', bangid: '27553319', source: 'mg', }, { id: 'mg__27186466', name: '热歌榜', bangid: '27186466', source: 'mg', }, { id: 'mg__27553408', name: '原创榜', bangid: '27553408', source: 'mg', }, { id: 'mg__75959118', name: '音乐风向榜', bangid: '75959118', source: 'mg', }, { id: 'mg__76557036', name: '彩铃分贝榜', bangid: '76557036', source: 'mg', }, { id: 'mg__76557745', name: '会员臻爱榜', bangid: '76557745', source: 'mg', }, { id: 'mg__23189800', name: '港台榜', bangid: '23189800', source: 'mg', }, { id: 'mg__23189399', name: '内地榜', bangid: '23189399', source: 'mg', }, { id: 'mg__19190036', name: '欧美榜', bangid: '19190036', source: 'mg', }, { id: 'mg__83176390', name: '国风金曲榜', bangid: '83176390', source: 'mg', }, ] export default { limit: 200, list: [ { id: 'mgyyb', name: '音乐榜', bangid: '27553319', }, { id: 'mgysb', name: '影视榜', bangid: '23603721', }, { id: 'mghybnd', name: '华语内地榜', bangid: '23603926', }, { id: 'mghyjqbgt', name: '华语港台榜', bangid: '23603954', }, { id: 'mgomb', name: '欧美榜', bangid: '23603974', }, { id: 'mgrhb', name: '日韩榜', bangid: '23603982', }, { id: 'mgwlb', name: '网络榜', bangid: '23604058', }, { id: 'mgclb', name: '彩铃榜', bangid: '23604023', }, { id: 'mgktvb', name: 'KTV榜', bangid: '23604040', }, { id: 'mgrcb', name: '原创榜', bangid: '23604032', }, ], getUrl(id, page) { return `https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/querycontentbyId.do?columnId=${id}&needAll=0` // return `http://m.music.migu.cn/migu/remoting/cms_list_tag?nid=${id}&pageSize=${this.limit}&pageNo=${page - 1}` }, successCode: '000000', requestBoardsObj: null, getBoardsData() { if (this.requestBoardsObj) this._requestBoardsObj.cancelHttp() this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/pc/bmw/rank/rank-index/v1.0', { // this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/rank-list/release', { // this.requestBoardsObj = httpFetch('https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/indexrank.do?templateVersion=8', { headers: { Referer: 'https://app.c.nf.migu.cn/', 'User-Agent': 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Mobile Safari/537.36', channel: '0146921', }, }) return this.requestBoardsObj.promise }, getData(url) { const requestObj = httpFetch(url) return requestObj.promise }, // filterBoardsData(listData, list = [], ids = new Set()) { // for (const item of listData) { // if (item.rankId && !ids.has(item.rankId)) { // ids.add(item.rankId) // list.push({ // id: 'mg__' + item.rankId, // name: item.rankName, // bangid: String(item.rankId), // source: 'mg', // }) // } else if (item.contents) this.filterBoardsData(item.contents, list, ids) // } // return list // }, // filterBoardsData(rawList) { // // console.log(rawList) // let list = [] // for (const board of rawList) { // if (board.template != 'group1') continue // for (const item of board.itemList) { // if ((item.template != 'row1' && item.template != 'grid1' && !item.actionUrl) || !item.actionUrl.includes('rank-info')) continue // let data = item.displayLogId.param // list.push({ // id: 'mg__' + data.rankId, // name: data.rankName, // bangid: String(data.rankId), // }) // } // } // return list // }, async getBoards(retryNum = 0) { // if (++retryNum > 3) return Promise.reject(new Error('try max num')) // let response // try { // response = await this.getBoardsData() // } catch (error) { // return this.getBoards(retryNum) // } // // console.log(response.body.data.contentItemList) // if (response.statusCode !== 200 || response.body.code !== this.successCode) return this.getBoards(retryNum) // const list = this.filterBoardsData(response.body.data.contents) // console.log(list) // // console.log(JSON.stringify(list)) // this.list = list // return { // list, // source: 'mg', // } this.list = boardList return { list: boardList, source: 'mg', } }, getList(bangid, page, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) return this.getData(this.getUrl(bangid, page)).then(({ statusCode, body }) => { // console.log(body) if (statusCode !== 200 || body.code !== this.successCode) return this.getList(bangid, page, retryNum) const list = filterMusicInfoList(body.columnInfo.contents.map(m => m.objectInfo)) return { total: list.length, list, limit: this.limit, page, source: 'mg', } }) }, getDetailPageUrl(id) { if (typeof id == 'string') id = id.replace('mg__', '') for (const item of boardList) { if (item.bangid == id) { return `https://music.migu.cn/v3/music/top/${item.webId}` } } return null }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/lyric.js ================================================ import { httpFetch } from '../../request' import { getMusicInfo } from './musicInfo' import { decrypt } from './utils/mrc' const mrcTools = { rxps: { lineTime: /^\s*\[(\d+),\d+\]/, wordTime: /\(\d+,\d+\)/, wordTimeAll: /(\(\d+,\d+\))/g, }, parseLyric(str) { str = str.replace(/\r/g, '') const lines = str.split('\n') const lxlrcLines = [] const lrcLines = [] for (const line of lines) { if (line.length < 6) continue let result = this.rxps.lineTime.exec(line) if (!result) continue const startTime = parseInt(result[1]) let time = startTime let ms = time % 1000 time /= 1000 let m = parseInt(time / 60).toString().padStart(2, '0') time %= 60 let s = parseInt(time).toString().padStart(2, '0') time = `${m}:${s}.${ms}` let words = line.replace(this.rxps.lineTime, '') lrcLines.push(`[${time}]${words.replace(this.rxps.wordTimeAll, '')}`) let times = words.match(this.rxps.wordTimeAll) if (!times) continue times = times.map(time => { const result = /\((\d+),(\d+)\)/.exec(time) return `<${parseInt(result[1]) - startTime},${result[2]}>` }) const wordArr = words.split(this.rxps.wordTime) const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('') lxlrcLines.push(`[${time}]${newWords}`) } return { lyric: lrcLines.join('\n'), lxlyric: lxlrcLines.join('\n'), } }, getText(url, tryNum = 0) { const requestObj = httpFetch(url, { headers: { Referer: 'https://app.c.nf.migu.cn/', 'User-Agent': 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Mobile Safari/537.36', channel: '0146921', }, }) return requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 200) return body if (tryNum > 5 || statusCode == 404) return Promise.reject(new Error('歌词获取失败')) return this.getText(url, ++tryNum) }) }, getMrc(url) { return this.getText(url).then(text => { return this.parseLyric(decrypt(text)) }) }, getLrc(url) { return this.getText(url).then(text => ({ lxlyric: '', lyric: text })) }, getTrc(url) { if (!url) return Promise.resolve('') return this.getText(url) }, async getMusicInfo(songInfo) { return songInfo.mrcUrl == null ? getMusicInfo(songInfo.copyrightId) : songInfo }, getLyric(songInfo) { return { promise: this.getMusicInfo(songInfo).then(info => { let p if (info.mrcUrl) p = this.getMrc(info.mrcUrl) else if (info.lrcUrl) p = this.getLrc(info.lrcUrl) if (p == null) return Promise.reject(new Error('获取歌词失败')) return Promise.all([p, this.getTrc(info.trcUrl)]).then(([lrcInfo, tlyric]) => { lrcInfo.tlyric = tlyric return lrcInfo }) }), cancelHttp() {}, } }, } export default { getLyric(songInfo) { let requestObj = mrcTools.getLyric(songInfo) return requestObj }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/musicInfo.js ================================================ import { sizeFormate, formatPlayTime } from '../../index' import { createHttpFetch } from './utils' import { formatSingerName } from '../utils' const createGetMusicInfosTask = (ids) => { let list = ids let tasks = [] while (list.length) { tasks.push(list.slice(0, 100)) if (list.length < 100) break list = list.slice(100) } let url = 'https://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?resourceType=2' return Promise.all(tasks.map(task => createHttpFetch(url, { method: 'POST', form: { resourceId: task.join('|'), }, }).then(data => data.resource))) } export const filterMusicInfoList = (rawList) => { // console.log(rawList) let ids = new Set() const list = [] rawList.forEach(item => { if (!item.songId || ids.has(item.songId)) return ids.add(item.songId) const types = [] const _types = {} item.newRateFormats?.forEach(type => { let size switch (type.formatType) { case 'PQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: '128k', size }) _types['128k'] = { size, } break case 'HQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: '320k', size }) _types['320k'] = { size, } break case 'SQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: 'flac', size }) _types.flac = { size, } break case 'ZQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } break } }) const intervalTest = /(\d\d:\d\d)$/.test(item.length) list.push({ singer: formatSingerName(item.artists, 'name'), name: item.songName, albumName: item.album, albumId: item.albumId, songmid: item.songId, copyrightId: item.copyrightId, source: 'mg', interval: intervalTest ? RegExp.$1 : null, img: item.albumImgs?.length ? item.albumImgs[0].img : null, lrc: null, lrcUrl: item.lrcUrl, mrcUrl: item.mrcUrl, trcUrl: item.trcUrl, otherSource: null, types, _types, typeUrl: {}, }) }) return list } export const filterMusicInfoListV5 = (rawList) => { // console.log(rawList) let ids = new Set() const list = [] rawList.forEach(item => { if (!item.songId || ids.has(item.songId)) return ids.add(item.songId) const types = [] const _types = {} item.audioFormats?.forEach(type => { let size switch (type.formatType) { case 'PQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: '128k', size }) _types['128k'] = { size, } break case 'HQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: '320k', size }) _types['320k'] = { size, } break case 'SQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: 'flac', size }) _types.flac = { size, } break case 'ZQ': size = sizeFormate(type.size ?? type.androidSize) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } break } }) list.push({ singer: formatSingerName(item.singerList, 'name'), name: item.songName, albumName: item.album, albumId: item.albumId, songmid: item.songId, copyrightId: item.copyrightId, source: 'mg', interval: formatPlayTime(item.duration), img: item.img3 || item.img2 || item.img1 || null, lrc: null, lrcUrl: item.lrcUrl, mrcUrl: item.mrcUrl, trcUrl: item.trcUrl, otherSource: null, types, _types, typeUrl: {}, }) }) return list } export const getMusicInfo = async(copyrightId) => { return getMusicInfos([copyrightId]).then(data => data[0]) } export const getMusicInfos = async(copyrightIds) => { return filterMusicInfoList(await Promise.all(createGetMusicInfosTask(copyrightIds)).then(data => data.flat())) } ================================================ FILE: src/renderer/utils/musicSdk/mg/musicSearch.js ================================================ import { httpFetch } from '../../request' import { sizeFormate, formatPlayTime } from '../../index' import { toMD5, formatSingerName } from '../utils' export const createSignature = (time, str) => { const deviceId = '963B7AA0D21511ED807EE5846EC87D20' const signatureMd5 = '6cdc72a439cef99a3418d2a78aa28c73' const sign = toMD5(`${str}${signatureMd5}yyapp2d16148780a1dcc7408e06336b98cfd50${deviceId}${time}`) return { sign, deviceId } } export default { limit: 20, total: 0, page: 0, allPage: 1, // 旧版API // musicSearch(str, page, limit) { // const searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, { // searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, { // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, { // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`) // // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, { // headers: { // // sign: 'c3b7ae985e2206e97f1b2de8f88691e2', // // timestamp: 1578225871982, // // appId: 'yyapp2', // // mode: 'android', // // ua: 'Android_migu', // // version: '6.9.4', // osVersion: 'android 7.0', // 'User-Agent': 'okhttp/3.9.1', // }, // }) // // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`) // return searchRequest.promise.then(({ body }) => body) // }, // handleResult(rawData) { // // console.log(rawData) // let ids = new Set() // const list = [] // rawData.forEach(item => { // if (ids.has(item.id)) return // ids.add(item.id) // const types = [] // const _types = {} // item.newRateFormats && item.newRateFormats.forEach(type => { // let size // switch (type.formatType) { // case 'PQ': // size = sizeFormate(type.size ?? type.androidSize) // types.push({ type: '128k', size }) // _types['128k'] = { // size, // } // break // case 'HQ': // size = sizeFormate(type.size ?? type.androidSize) // types.push({ type: '320k', size }) // _types['320k'] = { // size, // } // break // case 'SQ': // size = sizeFormate(type.size ?? type.androidSize) // types.push({ type: 'flac', size }) // _types.flac = { // size, // } // break // case 'ZQ': // size = sizeFormate(type.size ?? type.androidSize) // types.push({ type: 'flac24bit', size }) // _types.flac24bit = { // size, // } // break // } // }) // const albumNInfo = item.albums && item.albums.length // ? { // id: item.albums[0].id, // name: item.albums[0].name, // } // : {} // list.push({ // singer: this.getSinger(item.singers), // name: item.name, // albumName: albumNInfo.name, // albumId: albumNInfo.id, // songmid: item.songId, // copyrightId: item.copyrightId, // source: 'mg', // interval: null, // img: item.imgItems && item.imgItems.length ? item.imgItems[0].img : null, // lrc: null, // lrcUrl: item.lyricUrl, // mrcUrl: item.mrcurl, // trcUrl: item.trcUrl, // otherSource: null, // types, // _types, // typeUrl: {}, // }) // }) // return list // }, musicSearch(str, page, limit) { const time = Date.now().toString() const signData = createSignature(time, str) const searchRequest = httpFetch(`https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=0&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0&sid=USS`, { headers: { uiVersion: 'A_music_3.6.1', deviceId: signData.deviceId, timestamp: time, sign: signData.sign, channel: '0146921', 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', }, }) return searchRequest.promise.then(({ body }) => body) }, filterData(rawData) { // console.log(rawData) const list = [] const ids = new Set() rawData.forEach(item => { item.forEach(data => { if (!data.songId || !data.copyrightId || ids.has(data.copyrightId)) return ids.add(data.copyrightId) const types = [] const _types = {} data.audioFormats && data.audioFormats.forEach(type => { let size switch (type.formatType) { case 'PQ': size = sizeFormate(type.asize ?? type.isize) types.push({ type: '128k', size }) _types['128k'] = { size, } break case 'HQ': size = sizeFormate(type.asize ?? type.isize) types.push({ type: '320k', size }) _types['320k'] = { size, } break case 'SQ': size = sizeFormate(type.asize ?? type.isize) types.push({ type: 'flac', size }) _types.flac = { size, } break case 'ZQ24': size = sizeFormate(type.asize ?? type.isize) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } break } }) let img = data.img3 || data.img2 || data.img1 || null if (img && !/https?:/.test(data.img3)) img = 'http://d.musicapp.migu.cn' + img list.push({ singer: formatSingerName(data.singerList), name: data.name, albumName: data.album, albumId: data.albumId, songmid: data.songId, copyrightId: data.copyrightId, source: 'mg', interval: formatPlayTime(data.duration), img, lrc: null, lrcUrl: data.lrcUrl, mrcUrl: data.mrcurl, trcUrl: data.trcUrl, types, _types, typeUrl: {}, }) }) }) return list }, search(str, page = 1, limit, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) if (limit == null) limit = this.limit // http://newlyric.kuwo.cn/newlyric.lrc?62355680 return this.musicSearch(str, page, limit).then(result => { // console.log(result) if (!result || result.code !== '000000') return Promise.reject(new Error(result ? result.info : '搜索失败')) const songResultData = result.songResultData || { resultList: [], totalCount: 0 } let list = this.filterData(songResultData.resultList) if (list == null) return this.search(str, page, limit, retryNum) this.total = parseInt(songResultData.totalCount) this.page = page this.allPage = Math.ceil(this.total / limit) return { list, allPage: this.allPage, limit, total: this.total, source: 'mg', } }) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/pic.js ================================================ import { httpFetch } from '../../request' import getSongId from './songId' export default { async getPicUrl(songId, tryNum = 0) { let requestObj = httpFetch(`http://music.migu.cn/v3/api/music/audioPlayer/getSongPic?songId=${songId}`, { headers: { Referer: 'http://music.migu.cn/v3/music/player/audio?from=migu', }, }) requestObj.promise.then(({ body }) => { if (body.returnCode !== '000000') { if (tryNum > 5) return Promise.reject(new Error('图片获取失败')) let tryRequestObj = this.getPic(songId, ++tryNum) requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) return tryRequestObj.promise } let url = body.largePic || body.mediumPic || body.smallPic if (!/https?:/.test(url)) url = 'http:' + url return url }) return requestObj }, async getPic(songInfo) { const songId = await getSongId(songInfo) return this.getPicUrl(songId) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/songId.js ================================================ // import { httpFetch } from '../../request' import { getMusicInfo } from './musicInfo' const getSongId = async(mInfo) => { if (mInfo.songmid != mInfo.copyrightId) return mInfo.songmid const musicInfo = await getMusicInfo(mInfo.copyrightId) return musicInfo.songmid } // export const getSongId = async(musicInfo, retry = 0) => { // if (musicInfo.songmid != musicInfo.copyrightId) return musicInfo.songmid // if (++retry > 2) return Promise.reject(new Error('max retry')) // const requestObj = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/listen-url?netType=00&resourceType=2&songId=${musicInfo.copyrightId}&toneFlag=PQ`, { // headers: { // 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', // channel: '0146921', // }, // }) // return requestObj.promise.then(({ body }) => { // console.log(body) // if (!body || body.code !== '000000') return this.getSongId(musicInfo, retry) // const id = body.data.songItem.songId // if (!id) throw new Error('failed') // return id // }) // } export default getSongId ================================================ FILE: src/renderer/utils/musicSdk/mg/songList.js ================================================ import { httpFetch } from '../../request' import { formatPlayCount } from '../../index' import { filterMusicInfoListV5 } from './musicInfo' import { createSignature } from './musicSearch' import { createHttpFetch } from './utils/index' // const tagData = { code: '000000', info: 'SUCCESS', columnInfo: { columnTitle: '分类', columnId: '15244430', columnPid: '15031270', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 6, columnStatus: 1, columnCreateTime: '2016-11-10 10:53:05.077', columntype: 2011, contents: [{ contentId: '18464615', relationType: 2011, objectInfo: { columnTitle: '热门', columnId: '18464615', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 8, columnStatus: 1, columnCreateTime: '2017-02-20 16:09:13.400', columntype: 2011, contents: [{ contentId: '1000001672', relationType: 4034, objectInfo: { tagId: '1000001672', tagName: '流行', resourceType: '2034' }, relationSort: 9 }, { contentId: '1003449727', relationType: 4034, objectInfo: { tagId: '1003449727', tagName: '厂牌', resourceType: '2034' }, relationSort: 8 }, { contentId: '1000001795', relationType: 4034, objectInfo: { tagId: '1000001795', tagName: '伤感', resourceType: '2034' }, relationSort: 7 }, { contentId: '1001076080', relationType: 4034, objectInfo: { tagId: '1001076080', tagName: '电影', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001675', relationType: 4034, objectInfo: { tagId: '1000001675', tagName: '中国风', resourceType: '2034' }, relationSort: 5 }, { contentId: '1000001635', relationType: 4034, objectInfo: { tagId: '1000001635', tagName: '经典老歌', resourceType: '2034' }, relationSort: 4 }, { contentId: '1000001831', relationType: 4034, objectInfo: { tagId: '1000001831', tagName: '翻唱', resourceType: '2034' }, relationSort: 3 }, { contentId: '1000001762', relationType: 4034, objectInfo: { tagId: '1000001762', tagName: '国语', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620410266029', customizedPicUrls: [] }, relationSort: 6 }, { contentId: '15244503', relationType: 2011, objectInfo: { columnTitle: '主题', columnId: '15244503', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 23, columnStatus: 1, columnCreateTime: '2016-11-10 10:54:10.261', columntype: 2011, contents: [{ contentId: '1003449727', relationType: 4034, objectInfo: { tagId: '1003449727', tagName: '厂牌', resourceType: '2034' }, relationSort: 29 }, { contentId: '1001076080', relationType: 4034, objectInfo: { tagId: '1001076080', tagName: '电影', resourceType: '2034' }, relationSort: 28 }, { contentId: '1001076078', relationType: 4034, objectInfo: { tagId: '1001076078', tagName: '电视剧', resourceType: '2034' }, relationSort: 27 }, { contentId: '1001076083', relationType: 4034, objectInfo: { tagId: '1001076083', tagName: '综艺', resourceType: '2034' }, relationSort: 26 }, { contentId: '1000001827', relationType: 4034, objectInfo: { tagId: '1000001827', tagName: 'KTV', resourceType: '2034' }, relationSort: 23 }, { contentId: '1000001698', relationType: 4034, objectInfo: { tagId: '1000001698', tagName: '爱情', resourceType: '2034' }, relationSort: 22 }, { contentId: '1000001635', relationType: 4034, objectInfo: { tagId: '1000001635', tagName: '经典老歌', resourceType: '2034' }, relationSort: 21 }, { contentId: '1001076096', relationType: 4034, objectInfo: { tagId: '1001076096', tagName: '网络热歌', resourceType: '2034' }, relationSort: 20 }, { contentId: '1000001780', relationType: 4034, objectInfo: { tagId: '1000001780', tagName: '儿童歌曲', resourceType: '2034' }, relationSort: 19 }, { contentId: '1000587702', relationType: 4034, objectInfo: { tagId: '1000587702', tagName: '广场舞', resourceType: '2034' }, relationSort: 18 }, { contentId: '1000587717', relationType: 4034, objectInfo: { tagId: '1000587717', tagName: '70后', resourceType: '2034' }, relationSort: 17 }, { contentId: '1000587718', relationType: 4034, objectInfo: { tagId: '1000587718', tagName: '80后', resourceType: '2034' }, relationSort: 16 }, { contentId: '1000587726', relationType: 4034, objectInfo: { tagId: '1000587726', tagName: '90后', resourceType: '2034' }, relationSort: 15 }, { contentId: '1000001670', relationType: 4034, objectInfo: { tagId: '1000001670', tagName: '红歌', resourceType: '2034' }, relationSort: 14 }, { contentId: '1000587698', relationType: 4034, objectInfo: { tagId: '1000587698', tagName: '游戏', resourceType: '2034' }, relationSort: 13 }, { contentId: '1000587706', relationType: 4034, objectInfo: { tagId: '1000587706', tagName: '动漫', resourceType: '2034' }, relationSort: 12 }, { contentId: '1000001675', relationType: 4034, objectInfo: { tagId: '1000001675', tagName: '中国风', resourceType: '2034' }, relationSort: 11 }, { contentId: '1000587712', relationType: 4034, objectInfo: { tagId: '1000587712', tagName: '青春校园', resourceType: '2034' }, relationSort: 10 }, { contentId: '1000587673', relationType: 4034, objectInfo: { tagId: '1000587673', tagName: '小清新', resourceType: '2034' }, relationSort: 9 }, { contentId: '1000093902', relationType: 4034, objectInfo: { tagId: '1000093902', tagName: 'DJ舞曲', resourceType: '2034' }, relationSort: 7 }, { contentId: '1000093963', relationType: 4034, objectInfo: { tagId: '1000093963', tagName: '广告', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001831', relationType: 4034, objectInfo: { tagId: '1000001831', tagName: '翻唱', resourceType: '2034' }, relationSort: 2 }, { contentId: '1003449726', relationType: 4034, objectInfo: { tagId: '1003449726', tagName: '读书', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620410266055', customizedPicUrls: [] }, relationSort: 5 }, { contentId: '15244509', relationType: 2011, objectInfo: { columnTitle: '风格', columnId: '15244509', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 12, columnStatus: 1, columnCreateTime: '2016-11-10 10:54:57.257', columntype: 2011, contents: [{ contentId: '1000001672', relationType: 4034, objectInfo: { tagId: '1000001672', tagName: '流行', resourceType: '2034' }, relationSort: 14 }, { contentId: '1000001808', relationType: 4034, objectInfo: { tagId: '1000001808', tagName: 'R&B', resourceType: '2034' }, relationSort: 13 }, { contentId: '1000001809', relationType: 4034, objectInfo: { tagId: '1000001809', tagName: '嘻哈', resourceType: '2034' }, relationSort: 12 }, { contentId: '1000001674', relationType: 4034, objectInfo: { tagId: '1000001674', tagName: '摇滚', resourceType: '2034' }, relationSort: 11 }, { contentId: '1000001682', relationType: 4034, objectInfo: { tagId: '1000001682', tagName: '电子', resourceType: '2034' }, relationSort: 10 }, { contentId: '1000001852', relationType: 4034, objectInfo: { tagId: '1000001852', tagName: '电子舞曲', resourceType: '2034' }, relationSort: 9 }, { contentId: '1000001681', relationType: 4034, objectInfo: { tagId: '1000001681', tagName: '爵士', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001683', relationType: 4034, objectInfo: { tagId: '1000001683', tagName: '乡村', resourceType: '2034' }, relationSort: 5 }, { contentId: '1000001851', relationType: 4034, objectInfo: { tagId: '1000001851', tagName: '蓝调', resourceType: '2034' }, relationSort: 4 }, { contentId: '1000001775', relationType: 4034, objectInfo: { tagId: '1000001775', tagName: '民谣', resourceType: '2034' }, relationSort: 3 }, { contentId: '1000001807', relationType: 4034, objectInfo: { tagId: '1000001807', tagName: '纯音乐', resourceType: '2034' }, relationSort: 2 }, { contentId: '1000001783', relationType: 4034, objectInfo: { tagId: '1000001783', tagName: '古典', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620410266033', customizedPicUrls: [] }, relationSort: 4 }, { contentId: '18464665', relationType: 2011, objectInfo: { columnTitle: '语种', columnId: '18464665', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 6, columnStatus: 1, columnCreateTime: '2017-02-20 16:07:16.566', columntype: 2011, contents: [{ contentId: '1000001762', relationType: 4034, objectInfo: { tagId: '1000001762', tagName: '国语', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001763', relationType: 4034, objectInfo: { tagId: '1000001763', tagName: '粤语', resourceType: '2034' }, relationSort: 5 }, { contentId: '1000001766', relationType: 4034, objectInfo: { tagId: '1000001766', tagName: '英语', resourceType: '2034' }, relationSort: 4 }, { contentId: '1000001599', relationType: 4034, objectInfo: { tagId: '1000001599', tagName: '韩语', resourceType: '2034' }, relationSort: 3 }, { contentId: '1000001767', relationType: 4034, objectInfo: { tagId: '1000001767', tagName: '日语', resourceType: '2034' }, relationSort: 2 }, { contentId: '1003449724', relationType: 4034, objectInfo: { tagId: '1003449724', tagName: '小语种', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620410266036', customizedPicUrls: [] }, relationSort: 3 }, { contentId: '18464583', relationType: 2011, objectInfo: { columnTitle: '心情', columnId: '18464583', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 13, columnStatus: 1, columnCreateTime: '2017-02-20 15:59:03.412', columntype: 2011, contents: [{ contentId: '1000587677', relationType: 4034, objectInfo: { tagId: '1000587677', tagName: '幸福', resourceType: '2034' }, relationSort: 15 }, { contentId: '1000587710', relationType: 4034, objectInfo: { tagId: '1000587710', tagName: '治愈', resourceType: '2034' }, relationSort: 14 }, { contentId: '1000001703', relationType: 4034, objectInfo: { tagId: '1000001703', tagName: '思念', resourceType: '2034' }, relationSort: 13 }, { contentId: '1000587667', relationType: 4034, objectInfo: { tagId: '1000587667', tagName: '期待', resourceType: '2034' }, relationSort: 12 }, { contentId: '1000001700', relationType: 4034, objectInfo: { tagId: '1000001700', tagName: '励志', resourceType: '2034' }, relationSort: 11 }, { contentId: '1000001694', relationType: 4034, objectInfo: { tagId: '1000001694', tagName: '欢快', resourceType: '2034' }, relationSort: 10 }, { contentId: '1002600588', relationType: 4034, objectInfo: { tagId: '1002600588', tagName: '叛逆', resourceType: '2034' }, relationSort: 9 }, { contentId: '1002600585', relationType: 4034, objectInfo: { tagId: '1002600585', tagName: '宣泄', resourceType: '2034' }, relationSort: 8 }, { contentId: '1000001696', relationType: 4034, objectInfo: { tagId: '1000001696', tagName: '怀旧', resourceType: '2034' }, relationSort: 7 }, { contentId: '1000587679', relationType: 4034, objectInfo: { tagId: '1000587679', tagName: '减压', resourceType: '2034' }, relationSort: 6 }, { contentId: '1000001699', relationType: 4034, objectInfo: { tagId: '1000001699', tagName: '寂寞', resourceType: '2034' }, relationSort: 5 }, { contentId: '1002600579', relationType: 4034, objectInfo: { tagId: '1002600579', tagName: '忧郁', resourceType: '2034' }, relationSort: 4 }, { contentId: '1000001795', relationType: 4034, objectInfo: { tagId: '1000001795', tagName: '伤感', resourceType: '2034' }, relationSort: 3 }], dataVersion: '1620410266187', customizedPicUrls: [] }, relationSort: 2 }, { contentId: '18464638', relationType: 2011, objectInfo: { columnTitle: '场景', columnId: '18464638', columnPid: '15244430', opNumItem: { playNum: 0, playNumDesc: '0', keepNum: 0, keepNumDesc: '0', commentNum: 0, commentNumDesc: '0', shareNum: 0, shareNumDesc: '0', orderNumByWeek: 0, orderNumByWeekDesc: '0', orderNumByTotal: 0, orderNumByTotalDesc: '0', thumbNum: 0, thumbNumDesc: '0', followNum: 0, followNumDesc: '0', subscribeNum: 0, subscribeNumDesc: '0', livePlayNum: 0, livePlayNumDesc: '0', popularNum: 0, popularNumDesc: '0', bookingNum: 0, bookingNumDesc: '0' }, contentsCount: 13, columnStatus: 1, columnCreateTime: '2017-02-20 16:02:59.711', columntype: 2011, contents: [{ contentId: '1000587689', relationType: 4034, objectInfo: { tagId: '1000587689', tagName: '清晨', resourceType: '2034' }, relationSort: 21 }, { contentId: '1000587690', relationType: 4034, objectInfo: { tagId: '1000587690', tagName: '夜晚', resourceType: '2034' }, relationSort: 20 }, { contentId: '1000587688', relationType: 4034, objectInfo: { tagId: '1000587688', tagName: '睡前安眠', resourceType: '2034' }, relationSort: 19 }, { contentId: '1003449726', relationType: 4034, objectInfo: { tagId: '1003449726', tagName: '读书', resourceType: '2034' }, relationSort: 18 }, { contentId: '1003449723', relationType: 4034, objectInfo: { tagId: '1003449723', tagName: '下午·茶', resourceType: '2034' }, relationSort: 16 }, { contentId: '1000093923', relationType: 4034, objectInfo: { tagId: '1000093923', tagName: '驾车', resourceType: '2034' }, relationSort: 15 }, { contentId: '1003449615', relationType: 4034, objectInfo: { tagId: '1003449615', tagName: '运动', resourceType: '2034' }, relationSort: 13 }, { contentId: '1000587694', relationType: 4034, objectInfo: { tagId: '1000587694', tagName: '散步', resourceType: '2034' }, relationSort: 12 }, { contentId: '1000001749', relationType: 4034, objectInfo: { tagId: '1000001749', tagName: '旅行', resourceType: '2034' }, relationSort: 11 }, { contentId: '1000587686', relationType: 4034, objectInfo: { tagId: '1000587686', tagName: '夜店', resourceType: '2034' }, relationSort: 10 }, { contentId: '1002600606', relationType: 4034, objectInfo: { tagId: '1002600606', tagName: '派对', resourceType: '2034' }, relationSort: 9 }, { contentId: '1000001634', relationType: 4034, objectInfo: { tagId: '1000001634', tagName: '咖啡馆', resourceType: '2034' }, relationSort: 3 }, { contentId: '1000587692', relationType: 4034, objectInfo: { tagId: '1000587692', tagName: '瑜伽', resourceType: '2034' }, relationSort: 1 }], dataVersion: '1620846028994', customizedPicUrls: [] }, relationSort: 1 }], dataVersion: '1620846028941', customizedPicUrls: [] } } export default { _requestObj_tags: null, _requestObj_list: null, limit_list: 30, limit_song: 50, successCode: '000000', cachedDetailInfo: {}, cachedUrl: {}, sortList: [ { name: '推荐', id: '15127315', // id: '1', }, // { // name: '最新', // id: '15127272', // // id: '2', // }, ], regExps: { list: /
  • .+?<\/li>/g, listInfo: /.+data-original="(.+?)".*data-id="(\d+)".*
    (.+?)<\/a>.+<\/i>(.+?)<\/div>/, // https://music.migu.cn/v3/music/playlist/161044573?page=1 listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/, }, tagsUrl: 'https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-taglist/release', // tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0', getSongListUrl(sortId, tagId, page) { // if (tagId == null) { // return sortId == 'recommend' // ? `https://music.migu.cn/v3/music/playlist?page=${page}&from=migu` // : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu` // } // return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu` if (!tagId) { // return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1` // return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}` // https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=50&start=2&templateVersion=5&type=1 // return `https://m.music.migu.cn/migu/remoting/playlist_bycolumnid_tag?playListType=2&type=1&columnId=${sortId}&startIndex=${(page - 1) * 10}` return `https://app.c.nf.migu.cn/pc/bmw/page-data/playlist-square-recommend/v1.0?templateVersion=2&pageNo=${page}` } // return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?area=2&count=${this.limit_list}&start=${page}&tags=${tagId}&templateVersion=5&type=3` return `https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=${page}&templateVersion=2&tagId=${tagId}` // return `https://m.music.migu.cn/migu/remoting/playlist_bycolumnid_tag?playListType=2&type=1&tagId=${tagId}&startIndex=${(page - 1) * 10}` }, getSongListDetailUrl(id, page) { return `https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0?pageNo=${page}&pageSize=${this.limit_song}&playlistId=${id}` // return `https://app.c.nf.migu.cn/MIGUM2.0/v1.0/user/queryMusicListSongs.do?musicListId=${id}&pageNo=${page}&pageSize=${this.limit_song}` }, defaultHeaders: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1', Referer: 'https://m.music.migu.cn/', // language: 'Chinese', // ua: 'Android_migu', // mode: 'android', // version: '6.8.5', }, getListDetailList(id, page, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) // https://h5.nf.migu.cn/app/v4/p/share/playlist/index.html?id=184187437&channel=0146921 // if (/playlist\/index\.html\?/.test(id)) { // id = id.replace(/.*(?:\?|&)id=(\d+)(?:&.*|$)/, '$1') // } else if ((/[?&:/]/.test(id))) id = id.replace(this.regExps.listDetailLink, '$1') const requestObj_listDetail = httpFetch(this.getSongListDetailUrl(id, page), { headers: this.defaultHeaders }) return requestObj_listDetail.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getListDetailList(id, page, ++tryNum) // console.log(JSON.stringify(body)) // console.log(body) return { list: filterMusicInfoListV5(body.data.songList), page, limit: this.limit_song, total: body.data.totalCount, source: 'mg', } }) }, getListDetailInfo(id, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) if (this.cachedDetailInfo[id]) return Promise.resolve(this.cachedDetailInfo[id]) const requestObj_listDetailInfo = httpFetch(`https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=${id}`, { headers: this.defaultHeaders, }) return requestObj_listDetailInfo.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum) // console.log(JSON.stringify(body)) // console.log(body) const cachedDetailInfo = this.cachedDetailInfo[id] = { name: body.data.title, img: body.data.imgItem.img, desc: body.data.summary, author: body.data.ownerName, play_count: formatPlayCount(body.data.opNumItem.playNum), } return cachedDetailInfo }) }, async getDetailUrl(link, page, retryNum = 0) { if (retryNum > 3) return Promise.reject(new Error('link try max num')) const requestObj_listDetailLink = httpFetch(link, { headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', Referer: link, }, }) const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise // console.log(body, location) if (statusCode > 400) return this.getDetailUrl(link, page, ++retryNum) if (location) { this.cachedUrl[link] = location return this.getListDetail(location, page) } return Promise.reject(new Error('link get failed')) }, getListDetail(id, page, retryNum = 0) { // 获取歌曲列表内的音乐 // https://h5.nf.migu.cn/app/v4/p/share/playlist/index.html?id=184187437&channel=0146921 // http://c.migu.cn/00bTY6?ifrom=babddaadfde4ebeda289d671ab62f236 // https://music.migu.cn/v5/#/playlist?playlistId=221573417 if (/\/playlist[/?]/.test(id)) { id = /(?:playlistId|id)=(\d+)/.exec(id)?.[1] if (!id) throw new Error('list detail id parse failed') } else if (this.regExps.listDetailLink.test(id)) { id = id.replace(this.regExps.listDetailLink, '$1') } else if ((/[?&:/]/.test(id))) { const url = this.cachedUrl[id] return url ? this.getListDetail(url, page) : this.getDetailUrl(id, page) } return Promise.all([ this.getListDetailList(id, page, retryNum), this.getListDetailInfo(id, retryNum), ]).then(([listData, info]) => { listData.info = info return listData }) }, // 获取列表数据 getList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page), { headers: this.defaultHeaders, // headers: { // sign: 'c3b7ae985e2206e97f1b2de8f88691e2', // timestamp: 1578225871982, // appId: 'yyapp2', // mode: 'android', // ua: 'Android_migu', // version: '6.9.4', // osVersion: 'android 7.0', // 'User-Agent': 'okhttp/3.9.1', // }, }) // return this._requestObj_list.promise.then(({ statusCode, body }) => { // if (statusCode !== 200) return this.getList(sortId, tagId, page) // let list = body.replace(/[\r\n]/g, '').match(this.regExps.list) // if (!list) return Promise.reject(new Error('获取列表失败')) // return list.map(item => { // let info = item.match(this.regExps.listInfo) // return { // play_count: info[4], // id: info[2], // author: '', // name: info[3], // time: '', // img: info[1], // grade: 0, // desc: '', // source: 'mg', // } // }) // }) // return this._requestObj_list.promise.then(({ body }) => { // console.log(body) // if (body.retCode !== '100000' || body.retMsg.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) // return { // list: this.filterList(body.retMsg.playlist), // total: parseInt(body.retMsg.countSize), // page, // limit: this.limit_list, // source: 'mg', // } // }) return this._requestObj_list.promise.then(({ body }) => { // console.log(body) // if (body.retCode !== '000000') return this.getList(sortId, tagId, page, ++tryNum) if (body.code !== '000000') return this.getList(sortId, tagId, page, ++tryNum) const list = body.data.contents ? this.filterList2(body.data.contents) : this.filterList(body.data.contentItemList[1].itemList) return { list, total: 99999, page, limit: this.limit_list, source: 'mg', } }) }, filterList2(listData, list = [], ids = new Set()) { for (const item of listData) { if (item.contents) this.filterList2(item.contents, list, ids) else if (item.resType == '2021' && !ids.has(item.resId)) { ids.add(item.resId) list.push({ id: String(item.resId), author: '', name: item.txt, // time: dateFormat(item.createTime, 'Y-M-D'), img: item.img, // grade: item.grade, // total: item.contentCount, desc: item.txt2, source: 'mg', }) } } return list }, filterList(rawData) { // console.log(rawData) return rawData.map(item => ({ play_count: item.barList[0]?.title, id: String(item.logEvent.contentId), author: '', name: item.title, // time: dateFormat(item.createTime, 'Y-M-D'), img: item.imageUrl, // grade: item.grade, // total: item.contentCount, desc: '', source: 'mg', })) }, // 获取标签 getTag(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.tagsUrl, { headers: this.defaultHeaders }) return this._requestObj_tags.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getTag(++tryNum) // console.log(body) return this.filterTagInfo(body.data) }) // return Promise.resolve(this.filterTagInfo(tagData.columnInfo.contents)) }, filterTagInfo(rawList) { return { hotTag: rawList[0].content.map(({ texts: [name, id] }) => ({ id, name, source: 'mg', })), tags: rawList.slice(1).map(({ header, content }) => ({ name: header.title, list: content.map(({ texts: [name, id] }) => ({ // parent_id: objectInfo.columnId, // parent_name: objectInfo.columnTitle, id, name, source: 'mg', })), })), source: 'mg', } // return { // hotTag: rawList[0].objectInfo.contents.map(item => ({ // id: item.objectInfo.tagId, // name: item.objectInfo.tagName, // source: 'mg', // })), // tags: rawList.slice(1).map(({ objectInfo }) => ({ // name: objectInfo.columnTitle, // list: objectInfo.contents.map(item => ({ // parent_id: objectInfo.columnId, // parent_name: objectInfo.columnTitle, // id: item.objectInfo.tagId, // name: item.objectInfo.tagName, // source: 'mg', // })), // })), // source: 'mg', // } }, getTags() { return this.getTag() }, getDetailPageUrl(id) { if (/playlist\/index\.html\?/.test(id)) { id = id.replace(/.*(?:\?|&)id=(\d+)(?:&.*|$)/, '$1') } else if (this.regExps.listDetailLink.test(id)) { id = id.replace(this.regExps.listDetailLink, '$1') } return `https://music.migu.cn/v3/music/playlist/${id}` }, filterSongListResult(raw) { const list = [] raw.forEach(item => { if (!item.id) return const playCount = parseInt(item.playNum) list.push({ play_count: isNaN(playCount) ? 0 : formatPlayCount(playCount), id: item.id, author: item.userName, name: item.name, img: item.musicListPicUrl, total: item.musicNum, source: 'mg', }) }) return list }, search(text, page, limit = 20) { const timeStr = Date.now().toString() const signResult = createSignature(timeStr, text) return createHttpFetch(`https://jadeite.migu.cn/music_search/v3/search/searchAll?isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A0%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22bestShow%22%3A0%2C%22songlist%22%3A1%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(text)}&pageNo=${page}&sort=0&sid=USS`, { headers: { uiVersion: 'A_music_3.6.1', deviceId: signResult.deviceId, timestamp: timeStr, sign: signResult.sign, channel: '0146921', 'User-Agent': 'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', }, }).then(body => { if (!body.songListResultData) throw new Error('get song list faild.') const list = this.filterSongListResult(body.songListResultData.result) return { list, limit, total: parseInt(body.songListResultData.totalCount), source: 'mg', } }) }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/mg/temp/leaderboard-old.js ================================================ import { httpFetch } from '../../../request' import { formatPlayTime } from '../../../index' // import { sizeFormate } from '../../index' // const boardList = [{ id: 'mg__27553319', name: '咪咕尖叫新歌榜', bangid: '27553319' }, { id: 'mg__27186466', name: '咪咕尖叫热歌榜', bangid: '27186466' }, { id: 'mg__27553408', name: '咪咕尖叫原创榜', bangid: '27553408' }, { id: 'mg__23189800', name: '咪咕港台榜', bangid: '23189800' }, { id: 'mg__23189399', name: '咪咕内地榜', bangid: '23189399' }, { id: 'mg__19190036', name: '咪咕欧美榜', bangid: '19190036' }, { id: 'mg__23189813', name: '咪咕日韩榜', bangid: '23189813' }, { id: 'mg__23190126', name: '咪咕彩铃榜', bangid: '23190126' }, { id: 'mg__15140045', name: '咪咕KTV榜', bangid: '15140045' }, { id: 'mg__15140034', name: '咪咕网络榜', bangid: '15140034' }, { id: 'mg__23217754', name: 'MV榜', bangid: '23217754' }, { id: 'mg__23218151', name: '新专辑榜', bangid: '23218151' }, { id: 'mg__21958042', name: 'iTunes榜', bangid: '21958042' }, { id: 'mg__21975570', name: 'billboard榜', bangid: '21975570' }, { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815' }, { id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' }, { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943' }, { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437' }] const boardList = [ { id: 'mg__27553319', name: '尖叫新歌榜', bangid: '27553319', webId: 'jianjiao_newsong' }, { id: 'mg__27186466', name: '尖叫热歌榜', bangid: '27186466', webId: 'jianjiao_hotsong' }, { id: 'mg__27553408', name: '尖叫原创榜', bangid: '27553408', webId: 'jianjiao_original' }, { id: 'mg__migumusic', name: '音乐榜', bangid: 'migumusic', webId: 'migumusic' }, { id: 'mg__movies', name: '影视榜', bangid: 'movies', webId: 'movies' }, { id: 'mg__23189800', name: '港台榜', bangid: '23189800', webId: 'hktw' }, { id: 'mg__23189399', name: '内地榜', bangid: '23189399', webId: 'mainland' }, { id: 'mg__19190036', name: '欧美榜', bangid: '19190036', webId: 'eur_usa' }, { id: 'mg__23189813', name: '日韩榜', bangid: '23189813', webId: 'jpn_kor' }, { id: 'mg__23190126', name: '彩铃榜', bangid: '23190126', webId: 'coloring' }, { id: 'mg__15140045', name: 'KTV榜', bangid: '15140045', webId: 'ktv' }, { id: 'mg__15140034', name: '网络榜', bangid: '15140034', webId: 'network' }, { id: 'mg__23217754', name: 'MV榜', bangid: '23217754', webId: 'mv' }, { id: 'mg__23218151', name: '新专辑榜', bangid: '23218151', webId: 'newalbum' }, { id: 'mg__21958042', name: '美国iTunes榜', bangid: '21958042', webId: 'itunes' }, { id: 'mg__21975570', name: '美国billboard榜', bangid: '21975570', webId: 'billboard' }, { id: 'mg__22272815', name: '台湾Hito中文榜', bangid: '22272815', webId: 'hito' }, { id: 'mg__22272904', name: '中国TOP排行榜', bangid: '22272904' }, { id: 'mg__22272943', name: '韩国Melon榜', bangid: '22272943', webId: 'mnet' }, { id: 'mg__22273437', name: '英国UK榜', bangid: '22273437', webId: 'uk' }, ] // const boardList = [ // { id: 'mg__jianjiao_newsong', bangid: 'jianjiao_newsong', name: '尖叫新歌榜' }, // { id: 'mg__jianjiao_hotsong', bangid: 'jianjiao_hotsong', name: '尖叫热歌榜' }, // { id: 'mg__jianjiao_original', bangid: 'jianjiao_original', name: '尖叫原创榜' }, // { id: 'mg__migumusic', bangid: 'migumusic', name: '音乐榜' }, // { id: 'mg__movies', bangid: 'movies', name: '影视榜' }, // { id: 'mg__mainland', bangid: 'mainland', name: '内地榜' }, // { id: 'mg__hktw', bangid: 'hktw', name: '港台榜' }, // { id: 'mg__eur_usa', bangid: 'eur_usa', name: '欧美榜' }, // { id: 'mg__jpn_kor', bangid: 'jpn_kor', name: '日韩榜' }, // { id: 'mg__coloring', bangid: 'coloring', name: '彩铃榜' }, // { id: 'mg__ktv', bangid: 'ktv', name: 'KTV榜' }, // { id: 'mg__network', bangid: 'network', name: '网络榜' }, // { id: 'mg__newalbum', bangid: 'newalbum', name: '新专辑榜' }, // { id: 'mg__mv', bangid: 'mv', name: 'MV榜' }, // { id: 'mg__itunes', bangid: 'itunes', name: '美国iTunes榜' }, // { id: 'mg__billboard', bangid: 'billboard', name: '美国billboard榜' }, // { id: 'mg__hito', bangid: 'hito', name: 'Hito中文榜' }, // { id: 'mg__mnet', bangid: 'mnet', name: '韩国Melon榜' }, // { id: 'mg__uk', bangid: 'uk', name: '英国UK榜' }, // ] export default { limit: 10000, getUrl(id, page) { const targetBoard = boardList.find(board => board.bangid == id) return `https://music.migu.cn/v3/music/top/${targetBoard.webId}` // return `http://m.music.migu.cn/migu/remoting/cms_list_tag?nid=${id}&pageSize=${this.limit}&pageNo=${page - 1}` }, successCode: '000000', requestBoardsObj: null, regExps: { listData: /var listData = (\{.+\})<\/script>/, }, getData(url) { const requestObj = httpFetch(url) return requestObj.promise }, getSinger(singers) { let arr = [] singers.forEach(singer => { arr.push(singer.name) }) return arr.join('、') }, getIntv(interval) { if (!interval) return 0 let intvArr = interval.split(':') let intv = 0 let unit = 1 while (intvArr.length) { intv += (intvArr.pop()) * unit unit *= 60 } return parseInt(intv) }, formateIntv() { }, filterData(rawData) { // console.log(JSON.stringify(rawData)) // console.log(rawData) let ids = new Set() const list = [] rawData.forEach(item => { if (ids.has(item.copyrightId)) return ids.add(item.copyrightId) const types = [] const _types = {} const size = null types.push({ type: '128k', size }) _types['128k'] = { size } if (item.hq) { const size = null types.push({ type: '320k', size }) _types['320k'] = { size } } if (item.sq) { const size = null types.push({ type: 'flac', size }) _types.flac = { size } } list.push({ singer: this.getSinger(item.singers), name: item.name, albumName: item.album && item.album.albumName, albumId: item.album && item.album.albumId, songmid: item.id, copyrightId: item.copyrightId, source: 'mg', interval: item.duration ? formatPlayTime(this.getIntv(item.duration)) : null, img: item.mediumPic ? `https:${item.mediumPic}` : null, lrc: null, // lrcUrl: item.lrcUrl, otherSource: null, types, _types, typeUrl: {}, }) }) return list }, filterBoardsData(rawList) { // console.log(rawList) let list = [] for (const board of rawList) { if (board.template != 'group1') continue for (const item of board.itemList) { if ((item.template != 'row1' && item.template != 'grid1' && !item.actionUrl) || !item.actionUrl.includes('rank-info')) continue let data = item.displayLogId.param list.push({ id: 'mg__' + data.rankId, name: data.rankName, bangid: String(data.rankId), }) } } return list }, async getBoards(retryNum = 0) { // if (++retryNum > 3) return Promise.reject(new Error('try max num')) // let response // try { // response = await this.getBoardsData() // } catch (error) { // return this.getBoards(retryNum) // } // // console.log(response.body.data.contentItemList) // if (response.statusCode !== 200 || response.body.code !== this.successCode) return this.getBoards(retryNum) // const list = this.filterBoardsData(response.body.data.contentItemList) // // console.log(list) // // console.log(JSON.stringify(list)) // this.list = list // return { // list, // source: 'mg', // } this.list = boardList return { list: boardList, source: 'mg', } }, getList(bangid, page, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) return this.getData(this.getUrl(bangid, page)).then(({ statusCode, body }) => { if (statusCode !== 200) return this.getList(bangid, page, retryNum) let listData = body.match(this.regExps.listData) if (!listData) return this.getList(bangid, page, retryNum) const datas = JSON.parse(RegExp.$1) // console.log(datas) listData = this.filterData(datas.songs.items) return { total: datas.songs.itemTotal, list: this.filterData(datas.songs.items), limit: this.limit, page, source: 'mg', } }) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/tipSearch.js ================================================ import { createHttpFetch } from './utils' export default { requestObj: null, cancelTipSearch() { if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp() }, tipSearchBySong(str) { this.cancelTipSearch() this.requestObj = createHttpFetch(`https://music.migu.cn/v3/api/search/suggest?keyword=${encodeURIComponent(str)}`, { headers: { referer: 'https://music.migu.cn/v3', }, }) return this.requestObj.then(body => { return body.songs }) }, handleResult(rawData) { return rawData.map(info => `${info.name} - ${info.singerName}`) }, async search(str) { return this.tipSearchBySong(str).then(result => this.handleResult(result)) }, } ================================================ FILE: src/renderer/utils/musicSdk/mg/utils/index.js ================================================ import { httpFetch } from '../../../request' /** * 创建一个适用于MG的Http请求 * @param {*} url * @param {*} options * @param {*} retryNum */ export const createHttpFetch = async(url, options, retryNum = 0) => { if (retryNum > 2) throw new Error('try max num') let result try { result = await httpFetch(url, options).promise } catch (err) { console.log(err) return createHttpFetch(url, options, ++retryNum) } if (result.statusCode !== 200 || ( (result.body.code !== undefined ? result.body.code : result.body.returnCode !== undefined ? result.body.returnCode : result.body.code ) !== '000000') ) return createHttpFetch(url, options, ++retryNum) if (result.body.data) return result.body.data return result.body } ================================================ FILE: src/renderer/utils/musicSdk/mg/utils/mrc.js ================================================ // const key = 'karakal@123Qcomyidongtiantianhaoting' const DELTA = 2654435769n const MIN_LENGTH = 32 // const SPECIAL_CHAR = '0' const keyArr = [ 27303562373562475n, 18014862372307051n, 22799692160172081n, 34058940340699235n, 30962724186095721n, 27303523720101991n, 27303523720101998n, 31244139033526382n, 28992395054481524n, ] const teaDecrypt = (data, key) => { const length = data.length const lengthBitint = BigInt(length) if (length >= 1) { // let j = data[data.length - 1]; let j2 = data[0] let j3 = toLong((6n + (52n / lengthBitint)) * DELTA) while (true) { let j4 = j3 if (j4 == 0n) break let j5 = toLong(3n & toLong(j4 >> 2n)) let j6 = lengthBitint while (true) { j6-- if (j6 > 0n) { let j7 = data[(j6 - 1n)] let i = j6 j2 = toLong(data[i] - (toLong(toLong(j2 ^ j4) + toLong(j7 ^ key[toLong(toLong(3n & j6) ^ j5)])) ^ toLong(toLong(toLong(j7 >> 5n) ^ toLong(j2 << 2n)) + toLong(toLong(j2 >> 3n) ^ toLong(j7 << 4n))))) data[i] = j2 } else break } let j8 = data[lengthBitint - 1n] j2 = toLong(data[0n] - toLong(toLong(toLong(key[toLong(toLong(j6 & 3n) ^ j5)] ^ j8) + toLong(j2 ^ j4)) ^ toLong(toLong(toLong(j8 >> 5n) ^ toLong(j2 << 2n)) + toLong(toLong(j2 >> 3n) ^ toLong(j8 << 4n))))) data[0] = j2 j3 = toLong(j4 - DELTA) } } return data } const longArrToString = (data) => { const arrayList = [] for (const j of data) arrayList.push(longToBytes(j).toString('utf16le')) return arrayList.join('') } // https://stackoverflow.com/a/29132118 const longToBytes = (l) => { const result = Buffer.alloc(8) for (let i = 0; i < 8; i++) { result[i] = parseInt(l & 0xFFn) l >>= 8n } return result } const toBigintArray = (data) => { const length = Math.floor(data.length / 16) const jArr = Array(length) for (let i = 0; i < length; i++) { jArr[i] = toLong(data.substring(i * 16, (i * 16) + 16)) } return jArr } // https://github.com/lyswhut/lx-music-desktop/issues/445#issuecomment-1139338682 const MAX = 9223372036854775807n const MIN = -9223372036854775808n const toLong = str => { const num = typeof str == 'string' ? BigInt('0x' + str) : str if (num > MAX) return toLong(num - (1n << 64n)) else if (num < MIN) return toLong(num + (1n << 64n)) return num } export const decrypt = (data) => { // console.log(data.length) // -3551594764563790630 // console.log(toLongArrayFromArr(Buffer.from(key))) // console.log(teaDecrypt(toBigintArray(data), keyArr)) // console.log(longArrToString(teaDecrypt(toBigintArray(data), keyArr))) // console.log(toByteArray(teaDecrypt(toBigintArray(data), keyArr))) return (data == null || data.length < MIN_LENGTH) ? data : longArrToString(teaDecrypt(toBigintArray(data), keyArr)) } // console.log(14895149309145760986n - ) // console.log(toLong('14895149309145760986')) // console.log(decrypt(str)) // console.log(decrypt(str)) // console.log(toByteArray([6048138644744000495n])) // console.log(toByteArray([16325999628386395n])) // console.log(toLong(90994076459972177136n)) ================================================ FILE: src/renderer/utils/musicSdk/options.js ================================================ export const bHh = '624868746c' export const headers = { 'User-Agent': 'lx-music request', [bHh]: [bHh], } export const timeout = 15000 ================================================ FILE: src/renderer/utils/musicSdk/tx/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_messoer = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/tx/${songInfo.songmid}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, getPic(songInfo) { return Promise.resolve(`https://y.gtimg.cn/music/photo_new/T002R500x500M000${songInfo.albumId}.jpg`) }, } export default api_messoer ================================================ FILE: src/renderer/utils/musicSdk/tx/comment.js ================================================ import { httpFetch } from '../../request' import { dateFormat2 } from '../../index' import getMusicInfo from './musicInfo' const emojis = { e400846: '😘', e400874: '😴', e400825: '😃', e400847: '😙', e400835: '😍', e400873: '😳', e400836: '😎', e400867: '😭', e400832: '😊', e400837: '😏', e400875: '😫', e400831: '😉', e400855: '😡', e400823: '😄', e400862: '😨', e400844: '😖', e400841: '😓', e400830: '😈', e400828: '😆', e400833: '😋', e400822: '😀', e400843: '😕', e400829: '😇', e400824: '😂', e400834: '😌', e400877: '😷', e400132: '🍉', e400181: '🍺', e401067: '☕️', e400186: '🥧', e400343: '🐷', e400116: '🌹', e400126: '🍃', e400613: '💋', e401236: '❤️', e400622: '💔', e400637: '💣', e400643: '💩', e400773: '🔪', e400102: '🌛', e401328: '🌞', e400420: '👏', e400914: '🙌', e400408: '👍', e400414: '👎', e401121: '✋', e400396: '👋', e400384: '👉', e401115: '✊', e400402: '👌', e400905: '🙈', e400906: '🙉', e400907: '🙊', e400562: '👻', e400932: '🙏', e400644: '💪', e400611: '💉', e400185: '🎁', e400655: '💰', e400325: '🐥', e400612: '💊', e400198: '🎉', e401685: '⚡️', e400631: '💝', e400768: '🔥', e400432: '👑', } const songIdMap = new Map() const promises = new Map() export default { _requestObj: null, _requestObj2: null, async getSongId({ songId, songmid }) { if (songId) return songId if (songIdMap.has(songmid)) return songIdMap.get(songmid) if (promises.has(songmid)) return (await promises.get(songmid)).songId const promise = getMusicInfo(songmid) promises.set(promise) const info = await promise songIdMap.set(songmid, info.songId) promises.delete(songmid) return info.songId }, async getComment(mInfo, page = 1, limit = 20) { if (this._requestObj) this._requestObj.cancelHttp() const songId = await this.getSongId(mInfo) const _requestObj = httpFetch('http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg', { method: 'POST', headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', }, form: { uin: '0', format: 'json', cid: '205360772', reqtype: '2', biztype: '1', topid: songId, cmd: '8', needmusiccrit: '1', pagenum: page - 1, pagesize: limit, }, }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.code !== 0) throw new Error('获取评论失败') // console.log(body, statusCode) const comment = body.comment return { source: 'tx', comments: this.filterNewComment(comment.commentlist), total: comment.commenttotal, page, limit, maxPage: Math.ceil(comment.commenttotal / limit) || 1, } }, async getHotComment(mInfo, page = 1, limit = 20) { // const _requestObj2 = httpFetch('http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg', { // method: 'POST', // headers: { // 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', // }, // form: { // uin: '0', // format: 'json', // cid: '205360772', // reqtype: '2', // biztype: '1', // topid: songId, // cmd: '9', // needmusiccrit: '1', // pagenum: page - 1, // pagesize: limit, // }, // }) if (this._requestObj2) this._requestObj2.cancelHttp() const songId = await this.getSongId(mInfo) const _requestObj2 = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'POST', body: { comm: { cv: 4747474, ct: 24, format: 'json', inCharset: 'utf-8', outCharset: 'utf-8', notice: 0, platform: 'yqq.json', needNewCode: 1, uin: 0, }, req: { module: 'music.globalComment.CommentRead', method: 'GetHotCommentList', param: { BizType: 1, BizId: String(songId), LastCommentSeqNo: '', PageSize: limit, PageNum: page - 1, HotType: 1, WithAirborne: 0, PicEnable: 1, }, }, }, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0', referer: 'https://y.qq.com/', origin: 'https://y.qq.com', }, }) const { body, statusCode } = await _requestObj2.promise // console.log('body', body) if (statusCode != 200 || body.code !== 0 || body.req.code !== 0) throw new Error('获取热门评论失败') const comment = body.req.data.CommentList return { source: 'tx', comments: this.filterHotComment(comment.Comments), total: comment.Total, page, limit, maxPage: Math.ceil(comment.Total / limit) || 1, } }, filterNewComment(rawList) { return rawList.map(item => { let time = this.formatTime(item.time) let timeStr = time ? dateFormat2(time) : null if (item.middlecommentcontent) { let firstItem = item.middlecommentcontent[0] firstItem.avatarurl = item.avatarurl firstItem.praisenum = item.praisenum item.avatarurl = null item.praisenum = null item.middlecommentcontent.reverse() } return { id: `${item.rootcommentid}_${item.commentid}`, rootId: item.rootcommentid, text: item.rootcommentcontent ? this.replaceEmoji(item.rootcommentcontent).replace(/\\n/g, '\n') : '', time: item.rootcommentid == item.commentid ? time : null, timeStr: item.rootcommentid == item.commentid ? timeStr : null, userName: item.rootcommentnick ? item.rootcommentnick.substring(1) : '', avatar: item.avatarurl, userId: item.encrypt_rootcommentuin, likedCount: item.praisenum, reply: item.middlecommentcontent ? item.middlecommentcontent.map(c => { // let index = c.subcommentid.lastIndexOf('_') return { id: `sub_${item.rootcommentid}_${c.subcommentid}`, text: this.replaceEmoji(c.subcommentcontent).replace(/\\n/g, '\n'), time: c.subcommentid == item.commentid ? time : null, timeStr: c.subcommentid == item.commentid ? timeStr : null, userName: c.replynick.substring(1), avatar: c.avatarurl, userId: c.encrypt_replyuin, likedCount: c.praisenum, } }) : [], } }) }, filterHotComment(rawList) { return rawList.map(item => { return { id: `${item.SeqNo}_${item.CmId}`, rootId: item.SeqNo, text: item.Content ? this.replaceEmoji(item.Content).replace(/\\n/g, '\n') : '', time: item.PubTime ? this.formatTime(item.PubTime) : null, timeStr: item.PubTime ? dateFormat2(this.formatTime(item.PubTime)) : null, userName: item.Nick ?? '', images: item.Pic ? [item.Pic] : [], avatar: item.Avatar, location: item.Location ? item.Location : '', userId: item.EncryptUin, likedCount: item.PraiseNum, reply: item.SubComments ? item.SubComments.map(c => { return { id: `sub_${c.SeqNo}_${c.CmId}`, text: this.replaceEmoji(c.Content).replace(/\\n/g, '\n'), time: c.PubTime ? this.formatTime(c.PubTime) : null, timeStr: c.PubTime ? dateFormat2(this.formatTime(c.PubTime)) : null, userName: c.Nick ?? '', avatar: c.Avatar, images: c.Pic ? [c.Pic] : [], userId: c.EncryptUin, likedCount: c.PraiseNum, } }) : [], } }) }, replaceEmoji(msg) { let rxp = /^\[em\](e\d+)\[\/em\]$/ let result = msg.match(/\[em\]e\d+\[\/em\]/g) if (!result) return msg result = Array.from(new Set(result)) for (let item of result) { let code = item.replace(rxp, '$1') msg = msg.replace(new RegExp(item.replace('[em]', '\\[em\\]').replace('[/em]', '\\[\\/em\\]'), 'g'), emojis[code] || '') } return msg }, formatTime(time) { return String(time).length < 10 ? null : parseInt(time + '000') }, } ================================================ FILE: src/renderer/utils/musicSdk/tx/hotSearch.js ================================================ import { httpFetch } from '../../request' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) // const _requestObj = httpFetch('https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg', { // method: 'get', // headers: { // Referer: 'https://y.qq.com/portal/player.html', // }, // }) const _requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'post', body: { comm: { ct: '19', cv: '1803', guid: '0', patch: '118', psrf_access_token_expiresAt: 0, psrf_qqaccess_token: '', psrf_qqopenid: '', psrf_qqunionid: '', tmeAppID: 'qqmusic', tmeLoginType: 0, uin: '0', wid: '0', }, hotkey: { method: 'GetHotkeyForQQMusicPC', module: 'tencent_musicsoso_hotkey.HotkeyService', param: { search_id: '', uin: 0, }, }, }, headers: { Referer: 'https://y.qq.com/portal/player.html', }, }) const { body, statusCode } = await _requestObj.promise // console.log(body) if (statusCode != 200 || body.code !== 0) throw new Error('获取热搜词失败') // console.log(body) return { source: 'tx', list: this.filterList(body.hotkey.data.vec_hotkey) } }, filterList(rawList) { return rawList.map(item => item.query) }, } ================================================ FILE: src/renderer/utils/musicSdk/tx/index.js ================================================ import leaderboard from './leaderboard' import lyric from './lyric' import songList from './songList' import musicSearch from './musicSearch' import { apis } from '../api-source' import hotSearch from './hotSearch' import comment from './comment' // import tipSearch from './tipSearch' const tx = { // tipSearch, leaderboard, songList, musicSearch, hotSearch, comment, getMusicUrl(songInfo, type) { return apis('tx').getMusicUrl(songInfo, type) }, getLyric(songInfo) { // let singer = songInfo.singer.indexOf('、') > -1 ? songInfo.singer.split('、')[0] : songInfo.singer return lyric.getLyric(songInfo) }, async getPic(songInfo) { return `https://y.gtimg.cn/music/photo_new/T002R500x500M000${songInfo.albumId}.jpg` }, getMusicDetailPageUrl(songInfo) { return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html` }, } export default tx ================================================ FILE: src/renderer/utils/musicSdk/tx/leaderboard.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, sizeFormate } from '../../index' import { formatSingerName } from '../utils' let boardList = [{ id: 'tx__4', name: '流行指数榜', bangid: '4' }, { id: 'tx__26', name: '热歌榜', bangid: '26' }, { id: 'tx__27', name: '新歌榜', bangid: '27' }, { id: 'tx__62', name: '飙升榜', bangid: '62' }, { id: 'tx__58', name: '说唱榜', bangid: '58' }, { id: 'tx__57', name: '喜力电音榜', bangid: '57' }, { id: 'tx__28', name: '网络歌曲榜', bangid: '28' }, { id: 'tx__5', name: '内地榜', bangid: '5' }, { id: 'tx__3', name: '欧美榜', bangid: '3' }, { id: 'tx__59', name: '香港地区榜', bangid: '59' }, { id: 'tx__16', name: '韩国榜', bangid: '16' }, { id: 'tx__60', name: '抖快榜', bangid: '60' }, { id: 'tx__29', name: '影视金曲榜', bangid: '29' }, { id: 'tx__17', name: '日本榜', bangid: '17' }, { id: 'tx__52', name: '腾讯音乐人原创榜', bangid: '52' }, { id: 'tx__36', name: 'K歌金曲榜', bangid: '36' }, { id: 'tx__61', name: '台湾地区榜', bangid: '61' }, { id: 'tx__63', name: 'DJ舞曲榜', bangid: '63' }, { id: 'tx__64', name: '综艺新歌榜', bangid: '64' }, { id: 'tx__65', name: '国风热歌榜', bangid: '65' }, { id: 'tx__67', name: '听歌识曲榜', bangid: '67' }, { id: 'tx__72', name: '动漫音乐榜', bangid: '72' }, { id: 'tx__73', name: '游戏音乐榜', bangid: '73' }, { id: 'tx__75', name: '有声榜', bangid: '75' }, { id: 'tx__131', name: '校园音乐人排行榜', bangid: '131' }] export default { limit: 300, list: [ { id: 'txlxzsb', name: '流行榜', bangid: 4, }, { id: 'txrgb', name: '热歌榜', bangid: 26, }, { id: 'txwlhgb', name: '网络榜', bangid: 28, }, { id: 'txdyb', name: '抖音榜', bangid: 60, }, { id: 'txndb', name: '内地榜', bangid: 5, }, { id: 'txxgb', name: '香港榜', bangid: 59, }, { id: 'txtwb', name: '台湾榜', bangid: 61, }, { id: 'txoumb', name: '欧美榜', bangid: 3, }, { id: 'txhgb', name: '韩国榜', bangid: 16, }, { id: 'txrbb', name: '日本榜', bangid: 17, }, { id: 'txtybb', name: 'YouTube榜', bangid: 128, }, ], listDetailRequest(id, period, limit) { // console.log(id, period, limit) return httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', }, body: { toplist: { module: 'musicToplist.ToplistInfoServer', method: 'GetDetail', param: { topid: id, num: limit, period, }, }, comm: { uin: 0, format: 'json', ct: 20, cv: 1859, }, }, }).promise }, regExps: { periodList: //g, period: /data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?<\/i>/, }, periods: {}, periodUrl: 'https://c.y.qq.com/node/pc/wk_v15/top.html', _requestBoardsObj: null, getBoardsData() { if (this._requestBoardsObj) this._requestBoardsObj.cancelHttp() this._requestBoardsObj = httpFetch('https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg?g_tk=1928093487&inCharset=utf-8&outCharset=utf-8¬ice=0&format=json&uin=0&needNewCode=1&platform=h5') return this._requestBoardsObj.promise }, getData(url) { const requestDataObj = httpFetch(url) return requestDataObj.promise }, filterData(rawList) { // console.log(rawList) return rawList.map(item => { let types = [] let _types = {} if (item.file.size_128mp3 !== 0) { let size = sizeFormate(item.file.size_128mp3) types.push({ type: '128k', size }) _types['128k'] = { size, } } if (item.file.size_320mp3 !== 0) { let size = sizeFormate(item.file.size_320mp3) types.push({ type: '320k', size }) _types['320k'] = { size, } } if (item.file.size_flac !== 0) { let size = sizeFormate(item.file.size_flac) types.push({ type: 'flac', size }) _types.flac = { size, } } if (item.file.size_hires !== 0) { let size = sizeFormate(item.file.size_hires) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } } // types.reverse() return { singer: formatSingerName(item.singer, 'name'), name: item.title, albumName: item.album.name, albumId: item.album.mid, source: 'tx', interval: formatPlayTime(item.interval), songId: item.id, albumMid: item.album.mid, strMediaMid: item.file.media_mid, songmid: item.mid, img: (item.album.name === '' || item.album.name === '空') ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : '' : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.album.mid}.jpg`, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, getPeriods(bangid) { return this.getData(this.periodUrl).then(({ body: html }) => { let result = html.match(this.regExps.periodList) if (!result) return Promise.reject(new Error('get data failed')) result.forEach(item => { let result = item.match(this.regExps.period) if (!result) return this.periods[result[2]] = { name: result[1], bangid: result[2], period: result[3], } }) const info = this.periods[bangid] return info && info.period }) }, filterBoardsData(rawList) { // console.log(rawList) let list = [] for (const board of rawList) { // 排除 MV榜 if (board.id == 201) continue if (board.topTitle.startsWith('巅峰榜·')) { board.topTitle = board.topTitle.substring(4, board.topTitle.length) } if (!board.topTitle.endsWith('榜')) board.topTitle += '榜' list.push({ id: 'tx__' + board.id, name: board.topTitle, bangid: String(board.id), }) } return list }, async getBoards(retryNum = 0) { // if (++retryNum > 3) return Promise.reject(new Error('try max num')) // let response // try { // response = await this.getBoardsData() // } catch (error) { // return this.getBoards(retryNum) // } // // console.log(response.body) // if (response.statusCode !== 200 || response.body.code !== 0) return this.getBoards(retryNum) // const list = this.filterBoardsData(response.body.data.topList) // console.log(list) // console.log(JSON.stringify(list)) // this.list = list // return { // list, // source: 'tx', // } this.list = boardList return { list: boardList, source: 'tx', } }, getList(bangid, page, retryNum = 0) { if (++retryNum > 3) return Promise.reject(new Error('try max num')) bangid = parseInt(bangid) let info = this.periods[bangid] let p = info ? Promise.resolve(info.period) : this.getPeriods(bangid) return p.then(period => { return this.listDetailRequest(bangid, period, this.limit).then(resp => { if (resp.body.code !== 0) return this.getList(bangid, page, retryNum) return { total: resp.body.toplist.data.songInfoList.length, list: this.filterData(resp.body.toplist.data.songInfoList), limit: this.limit, page: 1, source: 'tx', } }) }) }, getDetailPageUrl(id) { if (typeof id == 'string') id = id.replace('tx__', '') return `https://y.qq.com/n/ryqq/toplist/${id}` }, } ================================================ FILE: src/renderer/utils/musicSdk/tx/lyric.js ================================================ import { httpFetch } from '../../request' import getMusicInfo from './musicInfo' import { rendererInvoke } from '@common/rendererIpc' import { WIN_MAIN_RENDERER_EVENT_NAME } from '@common/ipcNames' const songIdMap = new Map() const promises = new Map() export const decodeLyric = (lrc, tlrc, rlrc) => rendererInvoke(WIN_MAIN_RENDERER_EVENT_NAME.handle_tx_decode_lyric, { lrc, tlrc, rlrc }) const parseTools = { rxps: { info: /^{"/, lineTime: /^\[(\d+),\d+\]/, lineTime2: /^\[([\d:.]+)\]/, wordTime: /\(\d+,\d+\)/, wordTimeAll: /(\(\d+,\d+\))/g, timeLabelFixRxp: /(?:\.0+|0+)$/, }, msFormat(timeMs) { if (Number.isNaN(timeMs)) return '' let ms = timeMs % 1000 timeMs /= 1000 let m = parseInt(timeMs / 60).toString().padStart(2, '0') timeMs %= 60 let s = parseInt(timeMs).toString().padStart(2, '0') return `[${m}:${s}.${String(ms).padStart(3, '0')}]` }, parseLyric(lrc) { lrc = lrc.trim() lrc = lrc.replace(/\r/g, '') if (!lrc) return { lyric: '', lxlyric: '' } const lines = lrc.split('\n') const lxlrcLines = [] const lrcLines = [] for (let line of lines) { line = line.trim() let result = this.rxps.lineTime.exec(line) if (!result) { if (line.startsWith('[offset')) { lxlrcLines.push(line) lrcLines.push(line) } if (this.rxps.lineTime2.test(line)) { // lxlrcLines.push(line) lrcLines.push(line) } continue } const startMsTime = parseInt(result[1]) const startTimeStr = this.msFormat(startMsTime) if (!startTimeStr) continue let words = line.replace(this.rxps.lineTime, '') lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`) let times = words.match(this.rxps.wordTimeAll) if (!times) continue times = times.map(time => { const result = /\((\d+),(\d+)\)/.exec(time) return `<${Math.max(parseInt(result[1]) - startMsTime, 0)},${result[2]}>` }) const wordArr = words.split(this.rxps.wordTime) const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('') lxlrcLines.push(`${startTimeStr}${newWords}`) } return { lyric: lrcLines.join('\n'), lxlyric: lxlrcLines.join('\n'), } }, parseRlyric(lrc) { lrc = lrc.trim() lrc = lrc.replace(/\r/g, '') if (!lrc) return { lyric: '', lxlyric: '' } const lines = lrc.split('\n') const lrcLines = [] for (let line of lines) { line = line.trim() let result = this.rxps.lineTime.exec(line) if (!result) continue const startMsTime = parseInt(result[1]) const startTimeStr = this.msFormat(startMsTime) if (!startTimeStr) continue let words = line.replace(this.rxps.lineTime, '') lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`) } return lrcLines.join('\n') }, removeTag(str) { return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '') }, getIntv(interval) { if (!interval) return 0 if (!interval.includes('.')) interval += '.0' let arr = interval.split(/:|\./) while (arr.length < 3) arr.unshift('0') const [m, s, ms] = arr return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms) }, fixRlrcTimeTag(rlrc, lrc) { // console.log(lrc) // console.log(rlrc) const rlrcLines = rlrc.split('\n') let lrcLines = lrc.split('\n') // let temp = [] let newLrc = [] rlrcLines.forEach((line) => { const result = this.rxps.lineTime2.exec(line) if (!result) return const words = line.replace(this.rxps.lineTime2, '') if (!words.trim()) return const t1 = this.getIntv(result[1]) while (lrcLines.length) { const lrcLine = lrcLines.shift() const lrcLineResult = this.rxps.lineTime2.exec(lrcLine) if (!lrcLineResult) continue const t2 = this.getIntv(lrcLineResult[1]) if (Math.abs(t1 - t2) < 100) { newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0])) break } // temp.push(line) } // lrcLines = [...temp, ...lrcLines] // temp = [] }) return newLrc.join('\n') }, fixTlrcTimeTag(tlrc, lrc) { // console.log(lrc) // console.log(tlrc) const tlrcLines = tlrc.split('\n') let lrcLines = lrc.split('\n') // let temp = [] let newLrc = [] tlrcLines.forEach((line) => { const result = this.rxps.lineTime2.exec(line) if (!result) return const words = line.replace(this.rxps.lineTime2, '') if (!words.trim()) return let time = result[1] if (time.includes('.')) { time += ''.padStart(3 - time.split('.')[1].length, '0') } const t1 = this.getIntv(time) while (lrcLines.length) { const lrcLine = lrcLines.shift() const lrcLineResult = this.rxps.lineTime2.exec(lrcLine) if (!lrcLineResult) continue const t2 = this.getIntv(lrcLineResult[1]) if (Math.abs(t1 - t2) < 100) { newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0])) break } // temp.push(line) } // lrcLines = [...temp, ...lrcLines] // temp = [] }) return newLrc.join('\n') }, parse(lrc, tlrc, rlrc) { const info = { lyric: '', tlyric: '', rlyric: '', lxlyric: '', } if (lrc) { let { lyric, lxlyric } = this.parseLyric(this.removeTag(lrc)) info.lyric = lyric info.lxlyric = lxlyric // console.log(lyric) // console.log(lxlyric) } if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric) if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric) // console.log(info.lyric) // console.log(info.tlyric) // console.log(info.rlyric) return info }, } export default { successCode: 0, async getSongId({ songId, songmid }) { if (songId) return songId if (songIdMap.has(songmid)) return songIdMap.get(songmid) if (promises.has(songmid)) return (await promises.get(songmid)).songId const promise = getMusicInfo(songmid) promises.set(promise) const info = await promise songIdMap.set(songmid, info.songId) promises.delete(songmid) return info.songId }, async parseLyric(lrc, tlrc, rlrc) { const { lyric, tlyric, rlyric } = await decodeLyric(lrc, tlrc, rlrc) // return { // } // console.log(lyric) // console.log(tlyric) // console.log(rlyric) return parseTools.parse(lyric, tlyric, rlyric) }, getLyric(mInfo, retryNum = 0) { if (retryNum > 3) return Promise.reject(new Error('Get lyric failed')) return { cancelHttp() { }, promise: this.getSongId(mInfo).then(songId => { const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'post', headers: { referer: 'https://y.qq.com', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', }, body: { comm: { ct: '19', cv: '1859', uin: '0', }, req: { method: 'GetPlayLyricInfo', module: 'music.musichallSong.PlayLyricInfo', param: { format: 'json', crypt: 1, ct: 19, cv: 1873, interval: 0, lrc_t: 0, qrc: 1, qrc_t: 0, roma: 1, roma_t: 0, songID: songId, trans: 1, trans_t: 0, type: -1, }, }, }, }) return requestObj.promise.then(({ body }) => { // console.log(body) if (body.code != this.successCode || body.req.code != this.successCode) return this.getLyric(songId, ++retryNum) const data = body.req.data return this.parseLyric(data.lyric, data.trans, data.roma) }) }), } }, } // export default { // regexps: { // matchLrc: /.+"lyric":"([\w=+/]*)".+/, // }, // getLyric(songmid) { // const requestObj = httpFetch(`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songmid}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`, { // headers: { // Referer: 'https://y.qq.com/portal/player.html', // }, // }) // requestObj.promise = requestObj.promise.then(({ body }) => { // if (body.code != 0 || !body.lyric) return Promise.reject(new Error('Get lyric failed')) // return { // lyric: decodeName(b64DecodeUnicode(body.lyric)), // tlyric: decodeName(b64DecodeUnicode(body.trans)), // } // }) // return requestObj // }, // } ================================================ FILE: src/renderer/utils/musicSdk/tx/musicInfo.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, sizeFormate } from '../../index' const getSinger = (singers) => { let arr = [] singers.forEach(singer => { arr.push(singer.name) }) return arr.join('、') } export default (songmid) => { const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', }, body: { comm: { ct: '19', cv: '1859', uin: '0', }, req: { module: 'music.pf_song_detail_svr', method: 'get_song_detail_yqq', param: { song_type: 0, song_mid: songmid, }, }, }, }) return requestObj.promise.then(({ body }) => { // console.log(body) if (body.code != 0 || body.req.code != 0) return Promise.reject(new Error('获取歌曲信息失败')) const item = body.req.data.track_info if (!item.file?.media_mid) return null let types = [] let _types = {} const file = item.file if (file.size_128mp3 != 0) { let size = sizeFormate(file.size_128mp3) types.push({ type: '128k', size }) _types['128k'] = { size, } } if (file.size_320mp3 !== 0) { let size = sizeFormate(file.size_320mp3) types.push({ type: '320k', size }) _types['320k'] = { size, } } if (file.size_flac !== 0) { let size = sizeFormate(file.size_flac) types.push({ type: 'flac', size }) _types.flac = { size, } } if (file.size_hires !== 0) { let size = sizeFormate(file.size_hires) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } } // types.reverse() let albumId = '' let albumName = '' if (item.album) { albumName = item.album.name albumId = item.album.mid } return { singer: getSinger(item.singer), name: item.title, albumName, albumId, source: 'tx', interval: formatPlayTime(item.interval), songId: item.id, albumMid: item.album?.mid ?? '', strMediaMid: item.file.media_mid, songmid: item.mid, img: (albumId === '' || albumId === '空') ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : '' : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`, types, _types, typeUrl: {}, } }) } ================================================ FILE: src/renderer/utils/musicSdk/tx/musicSearch.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, sizeFormate } from '../../index' import { formatSingerName } from '../utils' export default { limit: 50, total: 0, page: 0, allPage: 1, successCode: 0, musicSearch(str, page, limit, retryNum = 0) { if (retryNum > 5) return Promise.reject(new Error('搜索失败')) // searchRequest = httpFetch(`https://c.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&new_json=1&remoteplace=sizer.yqq.song_next&searchid=49252838123499591&t=0&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`) // const searchRequest = httpFetch(`https://shc.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&remoteplace=txt.yqq.top&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&cv=4747474&ct=24&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&uin=0&hostUin=0&loginUin=0`) const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'post', headers: { 'User-Agent': 'QQMusic 14090508(android 12)', }, body: { comm: { ct: '11', cv: '14090508', v: '14090508', tmeAppID: 'qqmusic', phonetype: 'EBG-AN10', deviceScore: '553.47', devicelevel: '50', newdevicelevel: '20', rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', os_ver: '12', OpenUDID: '0', OpenUDID2: '0', QIMEI36: '0', udid: '0', chid: '0', aid: '0', oaid: '0', taid: '0', tid: '0', wid: '0', uid: '0', sid: '0', modeSwitch: '6', teenMode: '0', ui_mode: '2', nettype: '1020', v4ip: '', }, req: { module: 'music.search.SearchCgiService', method: 'DoSearchForQQMusicMobile', param: { search_type: 0, query: str, page_num: page, num_per_page: limit, highlight: 0, nqc_flag: 0, multi_zhida: 0, cat: 2, grp: 1, sin: 0, sem: 0, }, }, }, }) // searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`) return searchRequest.promise.then(({ body }) => { // console.log(body) if (body.code != this.successCode || body.req.code != this.successCode) return this.musicSearch(str, page, limit, ++retryNum) return body.req.data }) }, handleResult(rawList) { // console.log(rawList) const list = [] rawList.forEach(item => { if (!item.file?.media_mid) return let types = [] let _types = {} const file = item.file if (file.size_128mp3 != 0) { let size = sizeFormate(file.size_128mp3) types.push({ type: '128k', size }) _types['128k'] = { size, } } if (file.size_320mp3 !== 0) { let size = sizeFormate(file.size_320mp3) types.push({ type: '320k', size }) _types['320k'] = { size, } } if (file.size_flac !== 0) { let size = sizeFormate(file.size_flac) types.push({ type: 'flac', size }) _types.flac = { size, } } if (file.size_hires !== 0) { let size = sizeFormate(file.size_hires) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } } // types.reverse() let albumId = '' let albumName = '' if (item.album) { albumName = item.album.name albumId = item.album.mid } list.push({ singer: formatSingerName(item.singer, 'name'), name: item.name + (item.title_extra ?? ''), albumName, albumId, source: 'tx', interval: formatPlayTime(item.interval), songId: item.id, albumMid: item.album?.mid ?? '', strMediaMid: item.file.media_mid, songmid: item.mid, img: (albumId === '' || albumId === '空') ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : '' : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`, types, _types, typeUrl: {}, }) }) // console.log(list) return list }, search(str, page = 1, limit) { if (limit == null) limit = this.limit // http://newlyric.kuwo.cn/newlyric.lrc?62355680 return this.musicSearch(str, page, limit).then(({ body, meta }) => { let list = this.handleResult(body.item_song) this.total = meta.estimate_sum this.page = page this.allPage = Math.ceil(this.total / limit) return Promise.resolve({ list, allPage: this.allPage, limit, total: this.total, source: 'tx', }) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/tx/singer.js ================================================ import { httpFetch } from '../../request' import { formatPlayTime, sizeFormate } from '../../index' import { formatSingerName } from '../utils' export const filterMusicInfoItem = item => { const types = [] const _types = {} if (item.file.size_128mp3 != 0) { let size = sizeFormate(item.file.size_128mp3) types.push({ type: '128k', size }) _types['128k'] = { size, } } if (item.file.size_320mp3 !== 0) { let size = sizeFormate(item.file.size_320mp3) types.push({ type: '320k', size }) _types['320k'] = { size, } } if (item.file.size_flac !== 0) { let size = sizeFormate(item.file.size_flac) types.push({ type: 'flac', size }) _types.flac = { size, } } if (item.file.size_hires !== 0) { let size = sizeFormate(item.file.size_hires) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } } const albumId = item.album.id ?? '' const albumMid = item.album.mid ?? '' const albumName = item.album.name ?? '' return { source: 'tx', singer: formatSingerName(item.singer, 'name'), name: item.title, albumName, albumId, albumMid, interval: formatPlayTime(item.interval), songId: item.id, songmid: item.mid, strMediaMid: item.file.media_mid, img: (albumId === '' || albumId === '空') ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : '' : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg`, types, _types, typeUrl: {}, } } /** * 创建一个适用于TX的Http请求 * @param {*} url * @param {*} options * @param {*} retryNum */ const createMusicuFetch = async(data, options, retryNum = 0) => { if (retryNum > 2) throw new Error('try max num') let result try { result = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { method: 'POST', body: { comm: { cv: 4747474, ct: 24, format: 'json', inCharset: 'utf-8', outCharset: 'utf-8', uin: 0, }, ...data, }, headers: { 'User-Angent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', }, }).promise } catch (err) { console.log(err) return createMusicuFetch(data, options, ++retryNum) } if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, options, ++retryNum) return result.body } export default { /** * 获取歌手信息 * @param {*} id */ getInfo(id) { return createMusicuFetch({ req_1: { module: 'music.musichallSinger.SingerInfoInter', method: 'GetSingerDetail', param: { singer_mid: [id], ex_singer: 1, wiki_singer: 1, group_singer: 0, pic: 1, photos: 0, }, }, req_2: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: 0, num: 1, songNumTag: 0, singerID: 0, }, }, req_3: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, order: 1, begin: 0, num: 1, }, }, }).then(body => { if (body.req_1.code != 0 || body.req_2 != 0 || body.req_3 != 0) throw new Error('get singer info faild.') const info = body.req_1.data.singer_list[0] const music = body.req_3.data const album = body.req_3.data return { source: 'tx', id: info.basic_info.singer_mid, info: { name: info.basic_info.name, desc: info.ex_info.desc, avatar: info.pic.pic, gender: info.ex_info.genre === 1 ? 'man' : 'woman', }, count: { music: music.totalNum, album: album.total, }, } }) }, /** * 获取歌手专辑列表 * @param {*} id * @param {*} page * @param {*} limit */ getAlbumList(id, page = 1, limit = 10) { if (page === 1) page = 0 return createMusicuFetch({ req: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: page * limit, num: limit, songNumTag: 0, singerID: 0, }, }, }).then(body => { if (body.req.code != 0) throw new Error('get singer album faild.') const list = this.filterAlbumList(body.req.data.albumList) return { source: 'tx', list, limit, page, total: body.req.data.total, } }) }, /** * 获取歌手歌曲列表 * @param {*} id * @param {*} page * @param {*} limit */ async getSongList(id, page = 1, limit = 100) { if (page === 1) page = 0 return createMusicuFetch({ req: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, order: 1, begin: page * limit, num: limit, }, }, }).then(body => { if (body.req.code != 0) throw new Error('get singer song list faild.') const list = this.filterSongList(body.req.data.songList) return { source: 'tx', list, limit, page, total: body.req.data.totalNum, } }) }, filterAlbumList(raw) { return raw.map(item => { return { id: item.albumID, mid: item.albumMid, count: item.totalNum, info: { name: item.albumName, author: item.singerName, img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.albumMid}.jpg`, desc: null, }, } }) }, filterSongList(raw) { raw.map(item => { return filterMusicInfoItem(item.songInfo) }) }, } ================================================ FILE: src/renderer/utils/musicSdk/tx/songList.js ================================================ import { httpFetch } from '../../request' import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../../index' import { formatSingerName } from '../utils' export default { _requestObj_tags: null, _requestObj_hotTags: null, _requestObj_list: null, limit_list: 36, limit_song: 100000, successCode: 0, sortList: [ { name: '最热', id: 5, }, { name: '最新', id: 2, }, ], regExps: { hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g, hotTag: /data-id="(\w+)">(.+?)<\/a>/, // https://y.qq.com/n/yqq/playlist/7217720898.html // https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare listDetailLink: /\/playlist\/(\d+)/, listDetailLink2: /id=(\d+)/, }, tagsUrl: 'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D', hotTagUrl: 'https://c.y.qq.com/node/pc/wk_v15/category_playlist.html', getListUrl(sortId, id, page) { if (id) { id = parseInt(id) return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ comm: { cv: 1602, ct: 20 }, playlist: { method: 'get_category_content', param: { titleid: id, caller: '0', category_id: id, size: this.limit_list, page: page - 1, use_page: 1, }, module: 'playlist.PlayListCategoryServer', }, }))}` } return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ comm: { cv: 1602, ct: 20 }, playlist: { method: 'get_playlist_by_tag', param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order: sortId, cur_page: page }, module: 'playlist.PlayListPlazaServer', }, }))}` }, getListDetailUrl(id) { return `https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid=${id}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0` }, // http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2849349915&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1 // 获取标签 getTag(tryNum = 0) { if (this._requestObj_tags) this._requestObj_tags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_tags = httpFetch(this.tagsUrl) return this._requestObj_tags.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getTag(++tryNum) return this.filterTagInfo(body.tags.data.v_group) }) }, // 获取标签 getHotTag(tryNum = 0) { if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_hotTags = httpFetch(this.hotTagUrl) return this._requestObj_hotTags.promise.then(({ statusCode, body }) => { if (statusCode !== 200) return this.getHotTag(++tryNum) return this.filterInfoHotTag(body) }) }, filterInfoHotTag(html) { let hotTag = html.match(this.regExps.hotTagHtml) const hotTags = [] if (!hotTag) return hotTags hotTag.forEach(tagHtml => { let result = tagHtml.match(this.regExps.hotTag) if (!result) return hotTags.push({ id: parseInt(result[1]), name: result[2], source: 'tx', }) }) return hotTags }, filterTagInfo(rawList) { return rawList.map(type => ({ name: type.group_name, list: type.v_item.map(item => ({ parent_id: type.group_id, parent_name: type.group_name, id: item.id, name: item.name, source: 'tx', })), })) }, // 获取列表数据 getList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) this._requestObj_list = httpFetch( this.getListUrl(sortId, tagId, page), ) // console.log(this.getListUrl(sortId, tagId, page)) return this._requestObj_list.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page) }) }, filterList(data, page) { return { list: data.v_playlist.map(item => ({ play_count: formatPlayCount(item.access_num), id: String(item.tid), author: item.creator_info.nick, name: item.title, time: item.modify_time ? dateFormat(item.modify_time * 1000, 'Y-M-D') : '', img: item.cover_url_medium, // grade: item.favorcnt / 10, total: item.song_ids?.length, desc: decodeName(item.desc).replace(/
    /g, '\n'), source: 'tx', })), total: data.total, page, limit: this.limit_list, source: 'tx', } }, filterList2({ content }, page) { // console.log(content.v_item) return { list: content.v_item.map(({ basic }) => ({ play_count: formatPlayCount(basic.play_cnt), id: String(basic.tid), author: basic.creator.nick, name: basic.title, // time: basic.publish_time, img: basic.cover.medium_url || basic.cover.default_url, // grade: basic.favorcnt / 10, desc: decodeName(basic.desc).replace(/
    /g, '\n'), source: 'tx', })), total: content.total_cnt, page, limit: this.limit_list, source: 'tx', } }, async handleParseId(link, retryNum = 0) { if (retryNum > 2) return Promise.reject(new Error('link try max num')) const requestObj_listDetailLink = httpFetch(link) const { headers: { location }, statusCode } = await requestObj_listDetailLink.promise // console.log(headers) if (statusCode > 400) return this.handleParseId(link, ++retryNum) return location == null ? link : location }, async getListId(id) { if ((/[?&:/]/.test(id))) { if (!this.regExps.listDetailLink.test(id)) { id = await this.handleParseId(id) } let result = this.regExps.listDetailLink.exec(id) if (!result) { result = this.regExps.listDetailLink2.exec(id) if (!result) throw new Error('failed') } id = result[1] // console.log(id) } return id }, // 获取歌曲列表内的音乐 async getListDetail(id, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) id = await this.getListId(id) const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), { headers: { Origin: 'https://y.qq.com', Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`, }, }) const { body } = await requestObj_listDetail.promise if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum) const cdlist = body.cdlist[0] return { list: this.filterListDetail(cdlist.songlist), page: 1, limit: cdlist.songlist.length + 1, total: cdlist.songlist.length, source: 'tx', info: { name: cdlist.dissname, img: cdlist.logo, desc: decodeName(cdlist.desc).replace(/
    /g, '\n'), author: cdlist.nickname, play_count: formatPlayCount(cdlist.visitnum), }, } }, filterListDetail(rawList) { // console.log(rawList) return rawList.map(item => { let types = [] let _types = {} if (item.file.size_128mp3 !== 0) { let size = sizeFormate(item.file.size_128mp3) types.push({ type: '128k', size }) _types['128k'] = { size, } } if (item.file.size_320mp3 !== 0) { let size = sizeFormate(item.file.size_320mp3) types.push({ type: '320k', size }) _types['320k'] = { size, } } if (item.file.size_flac !== 0) { let size = sizeFormate(item.file.size_flac) types.push({ type: 'flac', size }) _types.flac = { size, } } if (item.file.size_hires !== 0) { let size = sizeFormate(item.file.size_hires) types.push({ type: 'flac24bit', size }) _types.flac24bit = { size, } } // types.reverse() return { singer: formatSingerName(item.singer, 'name'), name: item.title, albumName: item.album.name, albumId: item.album.mid, source: 'tx', interval: formatPlayTime(item.interval), songId: item.id, albumMid: item.album.mid, strMediaMid: item.file.media_mid, songmid: item.mid, img: (item.album.name === '' || item.album.name === '空') ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : '' : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${item.album.mid}.jpg`, lrc: null, otherSource: null, types, _types, typeUrl: {}, } }) }, getTags() { return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({ tags, hotTag, source: 'tx' })) }, async getDetailPageUrl(id) { id = await this.getListId(id) return `https://y.qq.com/n/ryqq/playlist/${id}` }, search(text, page, limit = 20, retryNum = 0) { if (retryNum > 5) throw new Error('max retry') return httpFetch(`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${page - 1}&num_per_page=${limit}&format=json&query=${encodeURIComponent(text)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', Referer: 'http://y.qq.com/portal/search.html', }, }) .promise.then(({ body }) => { if (body.code != 0) return this.search(text, page, limit, ++retryNum) // console.log(body.data.list) return { list: body.data.list.map(item => { return { play_count: formatPlayCount(item.listennum), id: String(item.dissid), author: decodeName(item.creator.name), name: decodeName(item.dissname), time: dateFormat(item.createtime, 'Y-M-D'), img: item.imgurl, // grade: item.favorcnt / 10, total: item.song_count, desc: decodeName(decodeName(item.introduction)).replace(/
    /g, '\n'), source: 'tx', } }), limit, total: body.data.sum, source: 'tx', } }) }, } // getList // getTags // getListDetail ================================================ FILE: src/renderer/utils/musicSdk/tx/tipSearch.js ================================================ import { httpFetch } from '../../request' export default { // regExps: { // relWord: /RELWORD=(.+)/, // }, requestObj: null, tipSearch(str) { this.cancelTipSearch() this.requestObj = httpFetch(`https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`, { headers: { Referer: 'https://y.qq.com/portal/player.html', }, }) return this.requestObj.promise.then(({ statusCode, body }) => { if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('请求失败')) return body.data }) }, handleResult(rawData) { return rawData.map(info => `${info.name} - ${info.singer}`) }, cancelTipSearch() { if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp() }, async search(str) { return this.tipSearch(str).then(result => this.handleResult(result.song.itemlist)) }, } ================================================ FILE: src/renderer/utils/musicSdk/utils.js ================================================ import crypto from 'crypto' import dns from 'dns' import { decodeName } from '@renderer/utils' export const toMD5 = str => crypto.createHash('md5').update(str).digest('hex') const ipMap = new Map() export const getHostIp = hostname => { const result = ipMap.get(hostname) if (typeof result === 'object') return result if (result === true) return ipMap.set(hostname, true) // console.log(hostname) dns.lookup(hostname, { // family: 4, all: false, }, (err, address, family) => { if (err) return console.log(err) // console.log(address, family) ipMap.set(hostname, { address, family }) }) } export const dnsLookup = (hostname, options, callback) => { const result = getHostIp(hostname) if (result) return callback(null, result.address, result.family) dns.lookup(hostname, options, callback) } /** * 格式化歌手 * @param singers 歌手数组 * @param nameKey 歌手名键值 * @param join 歌手分割字符 */ export const formatSingerName = (singers, nameKey = 'name', join = '、') => { if (Array.isArray(singers)) { const singer = [] singers.forEach(item => { let name = item[nameKey] if (!name) return singer.push(name) }) return decodeName(singer.join(join)) } return decodeName(String(singers ?? '')) } ================================================ FILE: src/renderer/utils/musicSdk/wy/api-test.js ================================================ import { httpFetch } from '../../request' import { requestMsg } from '../../message' import { headers, timeout } from '../options' import { dnsLookup } from '../utils' const api_test = { getMusicUrl(songInfo, type) { const requestObj = httpFetch(`http://ts.tempmusics.tk/url/wy/${songInfo.songmid}/${type}`, { method: 'get', timeout, headers, lookup: dnsLookup, family: 4, }) requestObj.promise = requestObj.promise.then(({ statusCode, body }) => { if (statusCode == 429) return Promise.reject(new Error(requestMsg.tooManyRequests)) switch (body.code) { case 0: return Promise.resolve({ type, url: body.data }) default: return Promise.reject(new Error(requestMsg.fail)) } }) return requestObj }, /* getPic(songInfo) { const requestObj = httpFetch(`http://localhost:3100/pic/wy/${songInfo.songmid}`, { method: 'get', timeout, headers, family: 4, }) requestObj.promise = requestObj.promise.then(({ body }) => { return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail)) }) return requestObj }, getLyric(songInfo) { const requestObj = httpFetch(`http://localhost:3100/lrc/wy/${songInfo.songmid}`, { method: 'get', timeout, headers, family: 4, }) requestObj.promise = requestObj.promise.then(({ body }) => { return body.code === 0 ? Promise.resolve(body.data) : Promise.reject(new Error(requestMsg.fail)) }) return requestObj }, */ } export default api_test ================================================ FILE: src/renderer/utils/musicSdk/wy/comment.js ================================================ import { httpFetch } from '../../request' import { weapi } from './utils/crypto' import { dateFormat2 } from '../../index' const emojis = [ ['大笑', '😃'], ['可爱', '😊'], ['憨笑', '☺️'], ['色', '😍'], ['亲亲', '😙'], ['惊恐', '😱'], ['流泪', '😭'], ['亲', '😚'], ['呆', '😳'], ['哀伤', '😔'], ['呲牙', '😁'], ['吐舌', '😝'], ['撇嘴', '😒'], ['怒', '😡'], ['奸笑', '😏'], ['汗', '😓'], ['痛苦', '😖'], ['惶恐', '😰'], ['生病', '😨'], ['口罩', '😷'], ['大哭', '😂'], ['晕', '😵'], ['发怒', '👿'], ['开心', '😄'], ['鬼脸', '😜'], ['皱眉', '😞'], ['流感', '😢'], ['爱心', '❤️'], ['心碎', '💔'], ['钟情', '💘'], ['星星', '⭐️'], ['生气', '💢'], ['便便', '💩'], ['强', '👍'], ['弱', '👎'], ['拜', '🙏'], ['牵手', '👫'], ['跳舞', '👯‍♀️'], ['禁止', '🙅‍♀️'], ['这边', '💁‍♀️'], ['爱意', '💏'], ['示爱', '👩‍❤️‍👨'], ['嘴唇', '👄'], ['狗', '🐶'], ['猫', '🐱'], ['猪', '🐷'], ['兔子', '🐰'], ['小鸡', '🐤'], ['公鸡', '🐔'], ['幽灵', '👻'], ['圣诞', '🎅'], ['外星', '👽'], ['钻石', '💎'], ['礼物', '🎁'], ['男孩', '👦'], ['女孩', '👧'], ['蛋糕', '🎂'], ['18', '🔞'], ['圈', '⭕'], ['叉', '❌'], ] const applyEmoji = text => { for (const e of emojis) text = text.replaceAll(`[${e[0]}]`, e[1]) return text } let cursorTools = { cache: {}, getCursor(id, page, limit) { let cacheData = this.cache[id] if (!cacheData) cacheData = this.cache[id] = {} let orderType let cursor let offset if (page == 1) { cacheData.page = 1 cursor = cacheData.cursor = cacheData.prevCursor = Date.now() orderType = 1 offset = 0 } else if (cacheData.page) { cursor = cacheData.cursor if (page > cacheData.page) { orderType = 1 offset = (page - cacheData.page - 1) * limit } else if (page < cacheData.page) { orderType = 0 offset = (cacheData.page - page - 1) * limit } else { cursor = cacheData.cursor = cacheData.prevCursor offset = cacheData.offset orderType = cacheData.orderType } } return { orderType, cursor, offset, } }, setCursor(id, cursor, orderType, offset, page) { let cacheData = this.cache[id] if (!cacheData) cacheData = this.cache[id] = {} cacheData.prevCursor = cacheData.cursor cacheData.cursor = cursor cacheData.orderType = orderType cacheData.offset = offset cacheData.page = page }, } export default { _requestObj: null, _requestObj2: null, async getComment({ songmid }, page = 1, limit = 20) { if (this._requestObj) this._requestObj.cancelHttp() const id = 'R_SO_4_' + songmid const cursorInfo = cursorTools.getCursor(songmid, page, limit) const _requestObj = httpFetch('https://music.163.com/weapi/comment/resource/comments/get', { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', origin: 'https://music.163.com', Refere: 'http://music.163.com/', }, form: weapi({ cursor: cursorInfo.cursor, offset: cursorInfo.offset, orderType: cursorInfo.orderType, pageNo: page, pageSize: limit, rid: id, threadId: id, }), }) const { body, statusCode } = await _requestObj.promise // console.log(body) if (statusCode != 200 || body.code !== 200) throw new Error('获取评论失败') cursorTools.setCursor(songmid, body.data.cursor, cursorInfo.orderType, cursorInfo.offset, page) return { source: 'wy', comments: this.filterComment(body.data.comments), total: body.data.totalCount, page, limit, maxPage: Math.ceil(body.data.totalCount / limit) || 1 } }, async getHotComment({ songmid }, page = 1, limit = 100) { if (this._requestObj2) this._requestObj2.cancelHttp() const id = 'R_SO_4_' + songmid page = page - 1 const _requestObj2 = httpFetch(`https://music.163.com/weapi/v1/resource/hotcomments/${id}`, { method: 'post', headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36', origin: 'https://music.163.com', Refere: 'http://music.163.com/', }, form: weapi({ rid: id, limit, offset: limit * page, beforeTime: Date.now().toString(), }), }) const { body, statusCode } = await _requestObj2.promise if (statusCode != 200 || body.code !== 200) throw new Error('获取热门评论失败') const total = body.total ?? 0 return { source: 'wy', comments: this.filterComment(body.hotComments), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } }, filterComment(rawList) { return rawList.map(item => { let data = { id: item.commentId, text: item.content ? applyEmoji(item.content) : '', time: item.time ? item.time : '', timeStr: item.time ? dateFormat2(item.time) : '', location: item.ipLocation?.location, userName: item.user.nickname, avatar: item.user.avatarUrl, userId: item.user.userId, likedCount: item.likedCount, reply: [], } let replyData = item.beReplied && item.beReplied[0] return replyData ? { id: item.commentId, rootId: replyData.beRepliedCommentId, text: replyData.content ? applyEmoji(replyData.content) : '', time: item.time, timeStr: null, location: replyData.ipLocation?.location, userName: replyData.user.nickname, avatar: replyData.user.avatarUrl, userId: replyData.user.userId, likedCount: null, reply: [data], } : data }) }, } ================================================ FILE: src/renderer/utils/musicSdk/wy/hotSearch.js ================================================ import { eapiRequest } from './utils/index' export default { _requestObj: null, async getList(retryNum = 0) { if (this._requestObj) this._requestObj.cancelHttp() if (retryNum > 2) return Promise.reject(new Error('try max num')) const _requestObj = eapiRequest('/api/search/chart/detail', { id: 'HOT_SEARCH_SONG#@#', }) const { body, statusCode } = await _requestObj.promise if (statusCode != 200 || body.code !== 200) throw new Error('获取热搜词失败') return { source: 'wy', list: this.filterList(body.data.itemList) } }, filterList(rawList) { return rawList.map(item => item.searchWord) }, } ================================================ FILE: src/renderer/utils/musicSdk/wy/index.js ================================================ import leaderboard from './leaderboard' import { apis } from '../api-source' import getLyric from './lyric' import getMusicInfo from './musicInfo' import musicSearch from './musicSearch' import songList from './songList' import hotSearch from './hotSearch' import comment from './comment' // import tipSearch from './tipSearch' const wy = { // tipSearch, leaderboard, musicSearch, songList, hotSearch, comment, getMusicUrl(songInfo, type) { return apis('wy').getMusicUrl(songInfo, type) }, getLyric(songInfo) { return getLyric(songInfo.songmid) }, getPic(songInfo) { const requestObj = getMusicInfo(songInfo.songmid) return requestObj.promise.then(info => info.al.picUrl) }, getMusicDetailPageUrl(songInfo) { return `https://music.163.com/#/song?id=${songInfo.songmid}` }, } export default wy ================================================ FILE: src/renderer/utils/musicSdk/wy/leaderboard.js ================================================ import { weapi } from './utils/crypto' import { httpFetch } from '../../request' import musicDetailApi from './musicDetail' const topList = [{ id: 'wy__19723756', name: '飙升榜', bangid: '19723756' }, { id: 'wy__3779629', name: '新歌榜', bangid: '3779629' }, { id: 'wy__2884035', name: '原创榜', bangid: '2884035' }, { id: 'wy__3778678', name: '热歌榜', bangid: '3778678' }, { id: 'wy__991319590', name: '说唱榜', bangid: '991319590' }, { id: 'wy__71384707', name: '古典榜', bangid: '71384707' }, { id: 'wy__1978921795', name: '电音榜', bangid: '1978921795' }, { id: 'wy__5453912201', name: '黑胶VIP爱听榜', bangid: '5453912201' }, { id: 'wy__71385702', name: 'ACG榜', bangid: '71385702' }, { id: 'wy__745956260', name: '韩语榜', bangid: '745956260' }, { id: 'wy__10520166', name: '国电榜', bangid: '10520166' }, { id: 'wy__180106', name: 'UK排行榜周榜', bangid: '180106' }, { id: 'wy__60198', name: '美国Billboard榜', bangid: '60198' }, { id: 'wy__3812895', name: 'Beatport全球电子舞曲榜', bangid: '3812895' }, { id: 'wy__21845217', name: 'KTV唛榜', bangid: '21845217' }, { id: 'wy__60131', name: '日本Oricon榜', bangid: '60131' }, { id: 'wy__2809513713', name: '欧美热歌榜', bangid: '2809513713' }, { id: 'wy__2809577409', name: '欧美新歌榜', bangid: '2809577409' }, { id: 'wy__27135204', name: '法国 NRJ Vos Hits 周榜', bangid: '27135204' }, { id: 'wy__3001835560', name: 'ACG动画榜', bangid: '3001835560' }, { id: 'wy__3001795926', name: 'ACG游戏榜', bangid: '3001795926' }, { id: 'wy__3001890046', name: 'ACG VOCALOID榜', bangid: '3001890046' }, { id: 'wy__3112516681', name: '中国新乡村音乐排行榜', bangid: '3112516681' }, { id: 'wy__5059644681', name: '日语榜', bangid: '5059644681' }, { id: 'wy__5059633707', name: '摇滚榜', bangid: '5059633707' }, { id: 'wy__5059642708', name: '国风榜', bangid: '5059642708' }, { id: 'wy__5338990334', name: '潜力爆款榜', bangid: '5338990334' }, { id: 'wy__5059661515', name: '民谣榜', bangid: '5059661515' }, { id: 'wy__6688069460', name: '听歌识曲榜', bangid: '6688069460' }, { id: 'wy__6723173524', name: '网络热歌榜', bangid: '6723173524' }, { id: 'wy__6732051320', name: '俄语榜', bangid: '6732051320' }, { id: 'wy__6732014811', name: '越南语榜', bangid: '6732014811' }, { id: 'wy__6886768100', name: '中文DJ榜', bangid: '6886768100' }, { id: 'wy__6939992364', name: '俄罗斯top hit流行音乐榜', bangid: '6939992364' }, { id: 'wy__7095271308', name: '泰语榜', bangid: '7095271308' }, { id: 'wy__7356827205', name: 'BEAT排行榜', bangid: '7356827205' }, { id: 'wy__7325478166', name: '编辑推荐榜VOL.44 天才女子摇滚乐队boygenius剖白卑微心迹', bangid: '7325478166' }, { id: 'wy__7603212484', name: 'LOOK直播歌曲榜', bangid: '7603212484' }, { id: 'wy__7775163417', name: '赏音榜', bangid: '7775163417' }, { id: 'wy__7785123708', name: '黑胶VIP新歌榜', bangid: '7785123708' }, { id: 'wy__7785066739', name: '黑胶VIP热歌榜', bangid: '7785066739' }, { id: 'wy__7785091694', name: '黑胶VIP爱搜榜', bangid: '7785091694' }, ] export default { limit: 100000, list: [ { id: 'wybsb', name: '飙升榜', bangid: '19723756', }, { id: 'wyrgb', name: '热歌榜', bangid: '3778678', }, { id: 'wyxgb', name: '新歌榜', bangid: '3779629', }, { id: 'wyycb', name: '原创榜', bangid: '2884035', }, { id: 'wygdb', name: '古典榜', bangid: '71384707', }, { id: 'wydouyb', name: '抖音榜', bangid: '2250011882', }, { id: 'wyhyb', name: '韩语榜', bangid: '745956260', }, { id: 'wydianyb', name: '电音榜', bangid: '1978921795', }, { id: 'wydjb', name: '电竞榜', bangid: '2006508653', }, { id: 'wyktvbb', name: 'KTV唛榜', bangid: '21845217', }, ], getUrl(id) { return `https://music.163.com/discover/toplist?id=${id}` }, regExps: { list: /