Repository: maotoumao/MusicFreeDesktop Branch: master Commit: f3b526a6c1ea Files: 410 Total size: 1020.1 KB Directory structure: gitextract_9squytp3/ ├── .github/ │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── build.yml │ └── release.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .vscode/ │ └── settings.json ├── LICENSE ├── README.md ├── changelog.md ├── config/ │ ├── webpack.main.config.ts │ ├── webpack.plugins.ts │ ├── webpack.renderer.config.ts │ └── webpack.rules.ts ├── eslint.config.mjs ├── forge.config.ts ├── package.json ├── release/ │ ├── build-windows.iss │ └── version.json ├── res/ │ ├── .service/ │ │ └── request-forwarder.js │ ├── lang/ │ │ ├── en-US.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ └── logo.icns ├── scripts/ │ └── feishu-upload.js ├── src/ │ ├── common/ │ │ ├── async-memoize.ts │ │ ├── camel-to-snake.ts │ │ ├── constant.ts │ │ ├── debounce.ts │ │ ├── event-wrapper.ts │ │ ├── file-util.ts │ │ ├── get-resource-path.ts │ │ ├── index-map.ts │ │ ├── is-renderer.ts │ │ ├── media-util.ts │ │ ├── normalize-util.ts │ │ ├── safe-serialization.ts │ │ ├── store.ts │ │ ├── thumb-bar-util.ts │ │ ├── time-util.ts │ │ ├── unique-map.ts │ │ └── void-callback.ts │ ├── hooks/ │ │ ├── useAppConfig.ts │ │ ├── useLocalFonts.ts │ │ ├── useMediaDevices.ts │ │ ├── useMounted.ts │ │ ├── useStateRef.ts │ │ └── useVirtualList.ts │ ├── main/ │ │ ├── deep-link/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── native_modules/ │ │ │ └── TaskbarThumbnailManager/ │ │ │ ├── TaskbarThumbnailManager.node │ │ │ └── TaskbarThumbnailManager.node.d.ts │ │ ├── tray-manager/ │ │ │ └── index.ts │ │ └── window-manager/ │ │ └── index.ts │ ├── preload/ │ │ ├── common-preload.ts │ │ ├── extension.ts │ │ └── index.ts │ ├── renderer/ │ │ ├── app.scss │ │ ├── app.tsx │ │ ├── components/ │ │ │ ├── A/ │ │ │ │ └── index.tsx │ │ │ ├── AnimatedDiv/ │ │ │ │ └── index.tsx │ │ │ ├── ArtistItem/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── BottomLoadingState/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Checkbox/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Condition/ │ │ │ │ └── index.tsx │ │ │ ├── ContextMenu/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── DragReceiver/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Empty/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Header/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── widgets/ │ │ │ │ ├── Navigator/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── SearchHistory/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Loading/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Modal/ │ │ │ │ ├── index.tsx │ │ │ │ └── templates/ │ │ │ │ ├── AddMusicToSheet/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── AddNewSheet/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── Base/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── ExitConfirm/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── ImportMusicSheet/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── PluginSubscription/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Reconfirm/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── SearchLyric/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── searchResultStore.ts │ │ │ │ │ │ └── useSearchLyric.ts │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── searchResult.scss │ │ │ │ │ └── searchResult.tsx │ │ │ │ ├── SelectOne/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── SimpleInputWithState/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Sparkles/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Update/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── WatchLocalDir/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── MusicBar/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── widgets/ │ │ │ │ ├── Controller/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Extra/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── MusicInfo/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── Slider/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicDetail/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ ├── store.ts │ │ │ │ └── widgets/ │ │ │ │ ├── Header/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── Lyric/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicDownloaded/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicFavorite/ │ │ │ │ └── index.tsx │ │ │ ├── MusicList/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicSheetlikeItem/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicSheetlikeList/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── MusicSheetlikeView/ │ │ │ │ ├── components/ │ │ │ │ │ ├── Body/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Header/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── store.ts │ │ │ ├── NoPlugin/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── Panel/ │ │ │ │ ├── index.tsx │ │ │ │ └── templates/ │ │ │ │ ├── Base/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── MusicComment/ │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useComment.ts │ │ │ │ ├── PlayList/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── UserVariables/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── SvgAsset/ │ │ │ │ └── index.tsx │ │ │ ├── SwitchCase/ │ │ │ │ └── index.tsx │ │ │ └── Tag/ │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── core/ │ │ │ ├── backup-resume/ │ │ │ │ └── index.ts │ │ │ ├── db/ │ │ │ │ └── music-sheet-db.ts │ │ │ ├── downloader/ │ │ │ │ ├── downloaded-sheet.ts │ │ │ │ ├── ee.ts │ │ │ │ ├── index.new.ts │ │ │ │ ├── index.ts │ │ │ │ └── store.ts │ │ │ ├── link-lyric/ │ │ │ │ └── index.ts │ │ │ ├── local-music/ │ │ │ │ ├── index.ts │ │ │ │ └── store.ts │ │ │ ├── music-sheet/ │ │ │ │ ├── backend/ │ │ │ │ │ └── index.ts │ │ │ │ ├── common/ │ │ │ │ │ └── default-sheet.ts │ │ │ │ ├── frontend/ │ │ │ │ │ ├── index.old.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── recently-playlist/ │ │ │ │ └── index.ts │ │ │ └── track-player/ │ │ │ ├── controller/ │ │ │ │ ├── audio-controller.ts │ │ │ │ └── controller-base.ts │ │ │ ├── enum.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ └── store.ts │ │ ├── document/ │ │ │ ├── bootstrap.ts │ │ │ ├── fallback.tsx │ │ │ ├── index.html │ │ │ ├── index.tsx │ │ │ ├── styles/ │ │ │ │ ├── base.scss │ │ │ │ ├── components.scss │ │ │ │ ├── fallback.scss │ │ │ │ ├── index.scss │ │ │ │ ├── tables.scss │ │ │ │ ├── utilities.scss │ │ │ │ └── variables.scss │ │ │ └── useBootstrap.ts │ │ ├── pages/ │ │ │ └── main-page/ │ │ │ ├── components/ │ │ │ │ └── SideBar/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── widgets/ │ │ │ │ ├── ListItem/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── MySheets/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── StarredSheets/ │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ └── views/ │ │ │ ├── album-view/ │ │ │ │ ├── hooks/ │ │ │ │ │ └── useAlbumDetail.ts │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── artist-view/ │ │ │ │ ├── components/ │ │ │ │ │ ├── Body/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── AlbumResult/ │ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── MusicResult/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Header/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ └── useQueryArtist.ts │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── store/ │ │ │ │ └── index.ts │ │ │ ├── download-view/ │ │ │ │ ├── components/ │ │ │ │ │ ├── Downloaded/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── Downloading/ │ │ │ │ │ ├── DownloadStatus.tsx │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── local-music-view/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── views/ │ │ │ │ ├── album/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── artist/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── folder/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── list/ │ │ │ │ └── index.tsx │ │ │ ├── music-sheet-view/ │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ ├── local-sheet/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── remote-sheet/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── usePluginSheetMusicList.ts │ │ │ │ │ └── index.tsx │ │ │ │ └── store/ │ │ │ │ └── musicSheetStore.ts │ │ │ ├── plugin-manager-view/ │ │ │ │ ├── components/ │ │ │ │ │ └── plugin-table/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── recently-play-view/ │ │ │ │ └── index.tsx │ │ │ ├── recommend-sheets-view/ │ │ │ │ ├── components/ │ │ │ │ │ └── Body/ │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── tag-panel.scss │ │ │ │ │ └── tag-panel.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useRecommendListTags.ts │ │ │ │ │ └── useRecommendSheets.ts │ │ │ │ └── index.tsx │ │ │ ├── search-view/ │ │ │ │ ├── components/ │ │ │ │ │ └── SearchResult/ │ │ │ │ │ ├── AlbumResult/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ArtistResult/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── MusicResult/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── SheetResult/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ └── useSearch.ts │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── store/ │ │ │ │ └── search-result.ts │ │ │ ├── setting-view/ │ │ │ │ ├── components/ │ │ │ │ │ ├── CheckBoxSettingItem/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ColorPickerSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── FontPickerSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── InputSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ListBoxSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── MultiRadioGroupSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── PathSettingItem/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── RadioGroupSettingItem/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.scss │ │ │ │ ├── index.tsx │ │ │ │ └── routers/ │ │ │ │ ├── About/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Backup/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Download/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Lyric/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Network/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Normal/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── PlayMusic/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── Plugin/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── ShortCut/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── theme-view/ │ │ │ │ ├── components/ │ │ │ │ │ ├── LocalThemes/ │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── RemoteThemes/ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ └── useRemoteThemes.ts │ │ │ │ │ │ ├── index.scss │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── ThemeItem/ │ │ │ │ │ ├── index.scss │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.scss │ │ │ │ └── index.tsx │ │ │ ├── toplist-detail-view/ │ │ │ │ ├── hooks/ │ │ │ │ │ └── useTopListDetail.ts │ │ │ │ └── index.tsx │ │ │ └── toplist-view/ │ │ │ ├── hooks/ │ │ │ │ └── useGetTopList.ts │ │ │ ├── index.scss │ │ │ ├── index.tsx │ │ │ └── store/ │ │ │ └── index.ts │ │ └── utils/ │ │ ├── check-update.ts │ │ ├── classnames.ts │ │ ├── create-tmp-file.ts │ │ ├── get-text-width.ts │ │ ├── get-url-ext.ts │ │ ├── groupBy.ts │ │ ├── img-on-error.ts │ │ ├── is-local-music.ts │ │ ├── lyric-parser.ts │ │ ├── preload-util.ts │ │ ├── raf2.ts │ │ ├── search-history.ts │ │ └── user-perference.ts │ ├── renderer-lrc/ │ │ ├── document/ │ │ │ ├── bootstrap.ts │ │ │ ├── index.html │ │ │ ├── index.tsx │ │ │ └── styles/ │ │ │ └── index.scss │ │ └── pages/ │ │ ├── index.scss │ │ └── index.tsx │ ├── renderer-minimode/ │ │ ├── document/ │ │ │ ├── bootstrap.ts │ │ │ ├── index.html │ │ │ ├── index.tsx │ │ │ └── styles/ │ │ │ └── index.scss │ │ └── pages/ │ │ ├── index.scss │ │ └── index.tsx │ ├── shared/ │ │ ├── app-config/ │ │ │ ├── default-app-config.ts │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ ├── database/ │ │ │ ├── main.ts │ │ │ ├── preload-backup.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ ├── global-context/ │ │ │ ├── internal/ │ │ │ │ └── common.ts │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ ├── renderer.ts │ │ │ └── type.d.ts │ │ ├── i18n/ │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ ├── renderer.ts │ │ │ └── type.d.ts │ │ ├── logger/ │ │ │ ├── main.ts │ │ │ └── renderer.ts │ │ ├── message-bus/ │ │ │ ├── main.ts │ │ │ ├── preload/ │ │ │ │ ├── extension.ts │ │ │ │ └── main.ts │ │ │ ├── renderer/ │ │ │ │ ├── extension.ts │ │ │ │ └── main.ts │ │ │ └── type.d.ts │ │ ├── plugin-manager/ │ │ │ ├── main/ │ │ │ │ ├── index.ts │ │ │ │ ├── internal-plugins/ │ │ │ │ │ └── local-plugin.ts │ │ │ │ ├── plugin-methods.ts │ │ │ │ ├── plugin.ts │ │ │ │ └── polyfill/ │ │ │ │ ├── react-native-cookies.ts │ │ │ │ └── storage.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ ├── service-manager/ │ │ │ ├── common.ts │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ ├── short-cut/ │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ ├── themepack/ │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ ├── renderer.ts │ │ │ └── type.d.ts │ │ ├── utils/ │ │ │ ├── main.ts │ │ │ ├── preload.ts │ │ │ └── renderer.ts │ │ └── window-drag/ │ │ ├── main.ts │ │ ├── preload.ts │ │ └── renderer.ts │ ├── types/ │ │ ├── app-config.d.ts │ │ ├── assets.d.ts │ │ ├── audio-controller.d.ts │ │ ├── common.d.ts │ │ ├── main/ │ │ │ └── window-manager.d.ts │ │ ├── media.d.ts │ │ ├── model.d.ts │ │ ├── plugin.d.ts │ │ ├── preload.d.ts │ │ ├── user-perference.d.ts │ │ └── window.d.ts │ └── webworkers/ │ ├── db-worker/ │ │ ├── const.ts │ │ ├── index.ts │ │ └── utils.ts │ ├── db-worker.ts │ ├── downloader.ts │ └── local-file-watcher.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ > [!CAUTION] > PR 请提交到 `dev` 分支 (Please submit PR to `dev` branch) ## PR 解决的问题 (PR Summary) > 简单描述一下这个 PR 要解决的问题,如果是 issues 中的问题,请附加 issue 编号 > (Please provide a brief description of this PR. If it aims to resolve an issue, please include the issue number) ## 影响范围 (Impact Scope) > 可选填写,说明一下改动的影响范围 > (Optional, explain the scope of impact of the changes) ## 截屏 (Screenshot) | 改动前 (Before) | 改动后 (After) | | ------ | ------ | | 在这里粘贴图片 (Paste screenshot here) | 在这里粘贴图片 (Paste screenshot here) | ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: workflow_dispatch: jobs: build-meta: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Generate build metadata run: | VERSION=$(node -p "require('./package.json').version") BRANCH=${GITHUB_REF_NAME} COMMIT=${GITHUB_SHA} echo "{\"branch\":\"${BRANCH}\",\"version\":\"${VERSION}\",\"commit\":\"${COMMIT}\"}" > build-meta.json cat build-meta.json - uses: actions/upload-artifact@v4 with: name: build-meta path: build-meta.json retention-days: 30 build-windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Read version run: | $version = node -p "require('./package.json').version" echo "VERSION=$version" >> $env:GITHUB_ENV - run: pnpm run package - uses: maotoumao/inno-setup-action-cli@main with: filepath: ./release/build-windows.iss variables: /DMyAppVersion=${{ env.VERSION }} /DMyAppId=${{ secrets.MYAPPID }} - name: Rename setup file run: | Rename-Item -Path "./out/MusicFreeSetup.exe" -NewName "MusicFree-${{ env.VERSION }}-win32-x64-setup.exe" - name: Generate portable run: | New-Item -ItemType Directory -Path "./out/MusicFree-win32-x64/portable" -Force - name: Archive portable run: | Compress-Archive -Path "./out/MusicFree-win32-x64/*" -DestinationPath "./out/MusicFree-${{ env.VERSION }}-win32-x64-portable.zip" - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-win32-x64-setup path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-setup.exe retention-days: 30 - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-win32-x64-portable path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-portable.zip retention-days: 30 build-windows-legacy: runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install - name: Read version run: | $version = node -p "require('./package.json').version" echo "VERSION=$version" >> $env:GITHUB_ENV - name: Override Win7 compatibility files run: | Get-ChildItem -Recurse -Filter "*.win7.ts" | ForEach-Object { $target = $_.FullName -replace '\.win7\.ts$', '.ts' Write-Host "Overriding: $target" Copy-Item $_.FullName $target -Force } - name: Install Electron 22 run: pnpm add electron@22 --save-dev - run: pnpm run package - uses: maotoumao/inno-setup-action-cli@main with: filepath: ./release/build-windows.iss variables: /DMyAppVersion=${{ env.VERSION }} /DMyAppId=${{ secrets.MYAPPID }} - name: Rename setup file run: | Rename-Item -Path "./out/MusicFreeSetup.exe" -NewName "MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup.exe" - name: Generate portable run: | New-Item -ItemType Directory -Path "./out/MusicFree-win32-x64/portable" -Force - name: Archive portable run: | Compress-Archive -Path "./out/MusicFree-win32-x64/*" -DestinationPath "./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable.zip" - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup.exe retention-days: 30 - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable.zip retention-days: 30 build-macos-x64: runs-on: macos-13 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Read version run: | VERSION=$(node -p "require('./package.json').version") echo "VERSION=$VERSION" >> $GITHUB_ENV - run: pnpm run make - name: Rename DMG run: mv "./out/make/MusicFree-${{ env.VERSION }}-x64.dmg" "./out/make/MusicFree-${{ env.VERSION }}-darwin-x64.dmg" - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-darwin-x64 path: ./out/make/MusicFree-${{ env.VERSION }}-darwin-x64.dmg retention-days: 30 build-macos-arm64: runs-on: macos-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Read version run: | VERSION=$(node -p "require('./package.json').version") echo "VERSION=$VERSION" >> $GITHUB_ENV - run: pnpm run make -- --arch=arm64 - name: Rename DMG run: mv "./out/make/MusicFree-${{ env.VERSION }}-arm64.dmg" "./out/make/MusicFree-${{ env.VERSION }}-darwin-arm64.dmg" - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-darwin-arm64 path: ./out/make/MusicFree-${{ env.VERSION }}-darwin-arm64.dmg retention-days: 30 build-linux: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y rpm - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Read version run: | VERSION=$(node -p "require('./package.json').version") echo "VERSION=$VERSION" >> $GITHUB_ENV - run: pnpm run make - name: Rename DEB run: | DEB_FILE=$(find ./out/make/deb/x64/ -name "*.deb" | head -1) if [ -n "$DEB_FILE" ]; then mv "$DEB_FILE" "./out/make/deb/x64/MusicFree-${{ env.VERSION }}-linux-amd64.deb" fi - name: Rename RPM run: | RPM_FILE=$(find ./out/make/rpm/x64/ -name "*.rpm" | head -1) if [ -n "$RPM_FILE" ]; then mv "$RPM_FILE" "./out/make/rpm/x64/MusicFree-${{ env.VERSION }}-linux-amd64.rpm" fi - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-linux-amd64-deb path: ./out/make/deb/x64/MusicFree-${{ env.VERSION }}-linux-amd64.deb retention-days: 30 - uses: actions/upload-artifact@v4 with: name: MusicFree-${{ env.VERSION }}-linux-amd64-rpm path: ./out/make/rpm/x64/MusicFree-${{ env.VERSION }}-linux-amd64.rpm retention-days: 30 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: workflow_dispatch: inputs: run_id: description: 'Build workflow run ID to download artifacts from' required: true type: string release_notes: description: 'Release notes (optional, overrides release/version.json changeLog)' required: false type: string permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download all artifacts from build run env: GH_TOKEN: ${{ github.token }} run: | mkdir -p artifacts gh run download ${{ inputs.run_id }} --dir artifacts - name: Read build metadata id: meta run: | META_FILE="artifacts/build-meta/build-meta.json" if [ ! -f "$META_FILE" ]; then echo "::error::build-meta.json not found. Make sure the build workflow completed successfully." exit 1 fi VERSION=$(jq -r '.version' "$META_FILE") BRANCH=$(jq -r '.branch' "$META_FILE") COMMIT=$(jq -r '.commit' "$META_FILE") echo "version=$VERSION" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> $GITHUB_OUTPUT echo "commit=$COMMIT" >> $GITHUB_OUTPUT echo "tag=v$VERSION" >> $GITHUB_OUTPUT # Auto-detect prerelease from branch name if [[ "$BRANCH" == *"dev"* ]] || [[ "$BRANCH" == *"beta"* ]] || [[ "$BRANCH" == *"alpha"* ]]; then echo "prerelease=true" >> $GITHUB_OUTPUT else echo "prerelease=false" >> $GITHUB_OUTPUT fi - name: Generate release notes id: notes env: RELEASE_NOTES_INPUT: ${{ inputs.release_notes }} run: | if [ -n "$RELEASE_NOTES_INPUT" ]; then printf '%s\n' "$RELEASE_NOTES_INPUT" > release-notes.md else jq -r '.changeLog | join("\n")' release/version.json > release-notes.md fi echo "=== Release notes ===" cat release-notes.md - name: Collect release assets run: | mkdir -p release-assets find artifacts -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" \) -exec cp {} release-assets/ \; echo "=== Release assets ===" ls -lh release-assets/ - name: Create tag env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ steps.meta.outputs.tag }}" COMMIT="${{ steps.meta.outputs.commit }}" # Check if tag already exists if gh api "repos/${{ github.repository }}/git/refs/tags/${TAG}" &>/dev/null; then echo "::error::Tag ${TAG} already exists. Aborting." exit 1 fi gh api "repos/${{ github.repository }}/git/refs" \ -X POST \ -f ref="refs/tags/${TAG}" \ -f sha="${COMMIT}" - name: Create GitHub Draft Release env: GH_TOKEN: ${{ github.token }} run: | gh release create "${{ steps.meta.outputs.tag }}" \ --title "MusicFree ${{ steps.meta.outputs.version }}" \ --notes-file release-notes.md \ --draft \ ${{ steps.meta.outputs.prerelease == 'true' && '--prerelease' || '' }} \ release-assets/* ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock .DS_Store # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # next.js build output .next # nuxt.js build output .nuxt # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # Webpack .webpack/ # Vite .vite/ # Electron-Forge out/ tmp plugins .VSCodeCounter .idea/ undefinedelectron-log-preload.js ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run lint-staged ================================================ FILE: .vscode/settings.json ================================================ { "editor.formatOnSave": true, "files.associations": { "*.html": "html", "map": "cpp" }, "editor.defaultFormatter": "dbaeumer.vscode-eslint", "[typescript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "eslint.format.enable": true } ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # MusicFree 桌面版 ![GitHub Repo stars](https://img.shields.io/github/stars/maotoumao/MusicFreeDesktop) ![GitHub forks](https://img.shields.io/github/forks/maotoumao/MusicFreeDesktop) ![star](https://gitcode.com/maotoumao/MusicFreeDesktop/star/badge.svg) ![GitHub License](https://img.shields.io/github/license/maotoumao/MusicFreeDesktop) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/maotoumao/MusicFreeDesktop/total) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/maotoumao/MusicFreeDesktop) ![GitHub package.json version](https://img.shields.io/github/package-json/v/maotoumao/MusicFreeDesktop) maotoumao%2FMusicFreeDesktop | Trendshift --- ## 项目使用约定: 本项目基于 AGPL 3.0 协议开源,使用此项目时请遵守开源协议。 除此外,希望你在使用代码时已经了解以下额外说明: 1. 打包、二次分发 **请保留代码出处**:https://github.com/maotoumao/MusicFree 2. 请不要用于商业用途,合法合规使用代码; 3. 如果开源协议变更,将在此 Github 仓库更新,不另行通知。 --- ## 简介 一个插件化、定制化、无广告的免费音乐播放器。 > 当前版本支持 Windows 和 macOS 和 Linux ### 下载地址 [飞书云文档](https://r0rvr854dd1.feishu.cn/drive/folder/IrVEfD67KlWZGkdqwjecLHFNnBb?from=from_copylink) ## 特性 - 插件化:本软件仅仅是一个播放器,本身**并不集成**任何平台的任何音源,所有的搜索、播放、歌单导入等功能全部基于**插件**。这也就意味着,**只要可以在互联网上搜索到的音源,只要有对应的插件,你都可以使用本软件进行搜索、播放等功能。** 关于插件的详细说明请参考 [安卓版 Readme 的插件部分](https://github.com/maotoumao/MusicFree#%E6%8F%92%E4%BB%B6)。 - 插件支持的功能:搜索(音乐、专辑、作者、歌单)、播放、查看专辑、查看作者详细信息、导入单曲、导入歌单、获取歌词等。 - 定制化:本软件可以通过主题包定义软件外观及背景,详见下方主题包一节。 - 无广告:基于 AGPL3.0 协议开源,将会保持免费。 - 隐私:软件所有数据存储在本地,本软件不会上传你的个人信息。 ## 插件 插件协议和安卓版完全相同。 [示例插件仓库](https://github.com/maotoumao/MusicFreePlugins),你可以根据[插件开发文档](https://musicfree.catcat.work/plugin/introduction.html) 开发适配于任意音源的插件。 ## 主题包 主题包是一个文件夹,文件夹内必须包含两个文件: ```bash index.css config.json ``` ### index.css index.css 中可以覆盖界面中的任何样式。你可以通过定义 css 变量来完成大部分颜色的替换,也可以查看源代码,根据类名等覆盖样式。 支持的 css 变量如下: ``` css :root { --primaryColor: #f17d34; // 主色调 --backgroundColor: #fdfdfd; // 背景色 --dividerColor: rgba(0, 0, 0, 0.1); // 分割线颜色 --listHoverColor: rgba(0, 0, 0, 0.05); // 列表悬浮颜色 --listActiveColor: rgba(0, 0, 0, 0.1); // 列表选中颜色 --textColor: #333333; // 主文本颜色 --maskColor: rgba(51, 51, 51, 0.2); // 遮罩层颜色 --shadowColor: rgba(0, 0, 0, 0.2); // 对话框等阴影颜色 /** --shadow: // shadow属性 */ --placeholderColor: #f4f4f4; // 输入区背景颜色 --successColor: #08A34C; // 成功颜色 --dangerColor: #FC5F5F; // 危险颜色 --infoColor: #0A95C8; // 通知颜色 --headerTextColor: white; // 顶部文本颜色 } ``` 具体的例子可以参考 [暗黑模式](https://github.com/maotoumao/MusicFreeThemePacks/blob/master/darkmode/index.css) 除了通过 css 定义常规样式外,也可以通过在 config.json 中定义 iframes 字段,用来把任意的 html 文件作为软件背景,这样可以实现一些单纯用 css 无法实现的效果。 ### config.json config.json 是一个配置文件。 ```json { "name": "主题包的名称", "preview": "#000000", // 预览图,支持颜色或图片; "description": "描述文本", "iframes": { "app": "http://musicfree.catcat.work", // 整个软件的背景 "header": "", // 头部区域的背景 "body": "", // 侧边栏+主页面区域的背景 "side-bar": "", // 侧边栏区域的背景 "page": "", // 主页面区域的背景 "music-bar": "", // 底部音乐栏的背景 } } ``` 如果需要指向本地的图片,可以通过 ```@/``` 表示主题包的路径;preview、iframes、以及 iframes 指向的 html 文件都会把 ```@/``` 替换为 ```主题包路径```。详情可参考 [樱花主题](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/sakura) ### 主题包示例 示例仓库:https://github.com/maotoumao/MusicFreeThemePacks 几个主题包效果截图: #### 暗黑模式 [源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/darkmode) ![暗黑模式](./.imgs/darkmode.png) #### 背景图片 [源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/night-star) ![背景图片](./.imgs/night-star.png) #### fliqlo [源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/fliqlo) ![fliqlo](./.imgs/fliqlo.gif) #### 樱花 [源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/sakura) ![樱花](./.imgs/sakura.gif) #### 雨季 [源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/rainy-season) ![雨季](./.imgs/rainy-season.gif) ## 启动项目 下载仓库代码之后,在根目录下执行: ```bash npm install npm start ``` ## 支持这个项目 如果你喜欢这个项目,或者希望我可以持续维护下去,你可以通过以下任何一种方式支持我;) 1. Star 这个项目,分享给你身边的人; 2. 关注公众号【一只猫头猫】获取最新信息; ## 截图 ![screenshot](./.imgs/screenshot.png) ![screenshot](./.imgs/screenshot1.png) ![screenshot](./.imgs/screenshot2.png) ================================================ FILE: changelog.md ================================================ `2025-10-24 v0.0.8` 1. 【修复】修复了一些可能导致白屏的问题 `2025-03-30 v0.0.7` 1. 【功能】开发者模式:狂点托盘图标可打开开发者工具 2. 【修复】修复退出应用时可能出现的进程残留的问题(感谢@dyfllll) 3. 【修复】修复windows控制中心无法控制暂停/播放状态的问题 4. 【修复】修复打开歌词窗口/迷你模式窗口歌词可能始终为空的问题 5. 【修复】修复配置出错时软件白屏的问题 6. 【修复】修复任务栏上的关闭按钮表现和设置中的选项不一致的问题 `2024-12-25 v0.0.6` 1. 【优化】大量代码重构 2. 【功能】新增播放失败时不寻找其他音质版本的配置 3. 【功能】歌单内支持通过 ctrl 键盘多选歌曲批量操作 4. 【功能】支持自定义主窗口大小 5. 【功能】支持自定义歌词窗口大小 6. 【功能】调整播放详情页面的样式 7. 【功能】支持了评论功能(需要插件支持) 8. 【修复】修复了歌单id无法带特殊字符的问题 9. 【修复】修复了音源太多时布局异常的问题 `2024-06.25 v0.0.5` 【修复】修复重启软件后歌单丢失的问题;如果未出现上述问题可忽略此版本更新 `2024-06.16 v0.0.4` 1. 【功能】播放列表支持拖拽排序 2. 【功能】支持多语言。本次支持简体中文、繁体中文、英文、西班牙语 3. 【功能】支持歌词翻译功能(需要插件实现 getLyric 方法) 4. 【功能】新增最近播放,默认保存最近播放的 500 首歌 5. 【功能】新增小窗模式 6. 【功能】新增音频设备移除时的行为设置,现在可以让拔掉耳机的时候停止播放了 7. 【功能】新增单独的主题页,可以在主题市场中直接使用主题;本地.mftheme 主题可以直接拖拽到播放器安装 8. 【优化】本地音乐会尝试读取本地路径下的同名 .lrc 文件作为歌词;同时会读取同名 -tr.lrc 文件作为翻译 9. 【修复】修复部分情况下本地歌词无法读取的问题 10. 【修复】修复 linux 托盘点击无效的问题 `2023-12.23 v0.0.3` 1. 【功能】插件支持拖拽排序,该排序会影响到搜索结果、排行榜、热门歌单的展示顺序 2. 【功能】播放列表支持多选快捷键(Ctrl + A 全选、按住 Shift 批量选择) 3. 【功能】本地音乐新增搜索功能 4. 【功能】歌单内歌曲支持拖拽排序 5. 【功能】新增了一些插件设置,比如启动软件时自动更新插件 6. 【功能】新增缓存设置,可以在设置中清空软件缓存 7. 【功能】新增网络代理设置 8. 【功能】插件协议更新:新增支持「用户变量」。 9. 【功能】插件协议更新:榜单列表支持分页 10. 【功能】歌单支持 WebDAV 备份;插件预置的 npm 包新增 webdav,配合 WebDAV 插件即可播放 WebDAV 源 11. 【优化】排序/过滤后,点击歌单列表会播放排序/过滤后的歌曲,而非全部歌曲 12. 【优化】优化批量删除歌曲失败时的表现 13. 【优化】优化歌单内歌曲较多时的体验 14. 【优化】优化歌曲名称超长时右下角菜单的显示 15. 【优化】加载本地歌曲时会自动识别歌曲元信息的编码,减少出现乱码的可能性 16. 【优化】优化本地歌曲过多时的拖拽表现 17. 【优化】优化 windows7/8 部分按钮的表现 18. 【修复】修复 macos、linux 本地歌曲无法播放的问题 19. 【修复】修复部分情况下无法右键打开下载文件夹的问题 20. 【修复】修复 macos 输入框无法粘贴的问题 21. 【修复】修复部分情况下快捷键无法删除的问题 22. 【修复】重写了本地歌单逻辑,修复收藏歌单部分情况下无法点击的问题 `2023-11.5 v0.0.2` 1. 【功能】支持播放 .m3u8 源 2. 【功能】打开播放列表时锚定到当前正在播放的歌曲 3. 【功能】新增搜索歌词功能,你可以在歌曲详情页右键点击,并单击【搜索歌词】功能唤起搜索弹窗 4. 【功能】重写了本地歌曲的导入机制,新增本地音乐视图(列表、作者、专辑、文件夹) 5. 【功能】新增支持隐藏歌曲列表的部分列 6. 【功能】新增快捷键:喜欢/不喜欢歌曲 7. 【功能】已经下载的歌曲/本地歌曲支持右键打开 8. 【功能, windows】新增缩略图配置,你可以选择在任务栏悬浮图标时展示原窗口或专辑封面 9. 【功能, windows】新增任务栏播放控制按钮 10. 【优化】优化主题包安装机制:取消原本安装文件夹的机制,修改为安装 .mftheme 或 .zip 的文件,支持批量安装 11. 【优化】优化了从热门歌单页详情页返回时的表现 12. 【优化】优化了歌曲详情页右键按钮的表现 13. 【优化】优化了主窗口和歌词窗口的通信机制 14. 【修复】修复作者页歌曲显示不全的问题 15. 【修复】修复包含特殊字符时下载失败的问题 16. 【修复, macos】修复 macos 图标显示异常的问题 17. 【修复, linux】修复 linux 无法最小化的问题 18. 【打包】新增 windows 免安装版、mac m1/m2 版、linux 版 19. 【版本号】桌面版后缀取消 -alpha 后缀,以正式版本号发布。 `2023.9.5 v0.0.1-alpha.0` 1. 【功能】新增搜索历史记录 2. 【功能】新增下载歌词、调整歌词字体大小功能 3. 【功能】桌面歌词支持自定义字体 4. 【功能】支持选择音频输出设备 5. 【功能】下载歌曲功能 6. 【功能】设置中新增快捷键配置 7. 【功能】支持 windows8.1 及以下系统(下载链接中的 windows-legacy-setup.exe,win10/11 下载哪个 exe 都行) 8. 【优化】调整左下角歌曲信息的可响应区域 9. 【修复】修复本地歌曲只显示 100 首的问题 10. 【修复】修复 mac 系统无法移动桌面歌词的问题 11. 【修复】修复本地插件安装失败的问题 ================================================ FILE: config/webpack.main.config.ts ================================================ import type { Configuration } from "webpack"; import path from "path"; import { rules } from "./webpack.rules"; export const mainConfig: Configuration = { /** * This is the main entry point for your application, it's the first file * that runs in the main process. */ entry: { index: "./src/main/index.ts", }, // Put your normal webpack config below here module: { rules, }, resolve: { extensions: [".js", ".ts", ".jsx", ".tsx", ".css", ".json", '.node'], alias: { "@": path.join(__dirname, "../src"), "@main": path.join(__dirname, "../src/main"), "@native": path.join(__dirname, "../src/main/native_modules"), "@shared": path.join(__dirname, "../src/shared") }, }, output: { filename: "[name].js", }, externals: ['sharp'] }; ================================================ FILE: config/webpack.plugins.ts ================================================ import type IForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; // eslint-disable-next-line @typescript-eslint/no-var-requires const ForkTsCheckerWebpackPlugin: typeof IForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); const relocateLoader = require("@vercel/webpack-asset-relocator-loader"); export const plugins = [ new ForkTsCheckerWebpackPlugin({ logger: "webpack-infrastructure", }), { apply(compiler: any) { compiler.hooks.compilation.tap( "webpack-asset-relocator-loader", (compilation: any) => { relocateLoader.initAssetCache(compilation, "native_modules"); } ); }, }, ]; ================================================ FILE: config/webpack.renderer.config.ts ================================================ import type { Configuration } from "webpack"; import path from "path"; import { rules } from "./webpack.rules"; import { plugins } from "./webpack.plugins"; rules.push( { test: /\.css$/, use: [{ loader: "style-loader" }, { loader: "css-loader" }], }, { test: /\.scss$/, use: [ { loader: "style-loader" }, { loader: "css-loader" }, { loader: "sass-loader" }, ], }, { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: "asset/resource", }, { test: /\.(png|jpg|jpeg|gif)$/i, type: "asset/resource", }, { test: /\.svg$/, use: [ { loader: "@svgr/webpack", options: { prettier: false, svgo: false, svgoConfig: { plugins: [{ removeViewBox: false }], }, titleProp: true, ref: true, }, }, ], } ); export const rendererConfig: Configuration = { module: { rules, }, plugins, resolve: { extensions: [".js", ".ts", ".jsx", ".tsx", ".css", ".scss"], alias: { "@": path.join(__dirname, "../src"), "@renderer": path.join(__dirname, "../src/renderer"), "@renderer-lrc": path.join(__dirname, "../src/renderer-lrc"), "@shared": path.join(__dirname, "../src/shared") }, }, externals: process.platform !== "darwin" ? ["fsevents"] : undefined, }; ================================================ FILE: config/webpack.rules.ts ================================================ import type { ModuleOptions } from 'webpack'; export const rules: Required['rules'] = [ // Add support for native node modules { // We're specifying native_modules in the test because the asset relocator loader generates a // "fake" .node file which is really a cjs file. test: /native_modules[/\\].+\.node$/, use: 'node-loader', }, { test: /[/\\]node_modules[/\\].+\.(m?js|node)$/, parser: { amd: false }, use: { loader: '@vercel/webpack-asset-relocator-loader', options: { outputAssetBase: 'native_modules', }, }, }, { test: /\.tsx?$/, exclude: /(node_modules|\.webpack)/, use: { loader: 'ts-loader', options: { transpileOnly: true, }, }, }, { test: /\.jsx?$/, use: { loader: 'babel-loader', options: { exclude: /node_modules/, presets: ['@babel/preset-react'] } } }, ]; ================================================ FILE: eslint.config.mjs ================================================ import globals from "globals"; import js from "@eslint/js"; import tseslint from "typescript-eslint"; import importPlugin from "eslint-plugin-import"; import stylistic from "@stylistic/eslint-plugin"; export default [ // JavaScript 推荐配置 js.configs.recommended, // TypeScript 推荐配置 ...tseslint.configs.recommended, // 全局配置 { languageOptions: { globals: { ...globals.browser, ...globals.node, ...globals.es6, }, }, }, // TypeScript 和 JavaScript 文件配置 { files: ["**/*.{js,mjs,cjs,ts,tsx}"], plugins: { import: importPlugin, "@stylistic": stylistic, }, rules: { // 保持原有的规则配置 "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-var-requires": "warn", "import/no-unresolved": "off", "@typescript-eslint/no-empty-interface": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-empty-function": "warn", "no-empty": "warn", "no-useless-catch": "warn", "prefer-const": "warn", // 样式规则迁移到 ESLint Stylistic "@stylistic/quotes": ["warn", "double"], "@stylistic/object-curly-spacing": ["error", "always"], "@stylistic/indent": ["error", 4], // 统一缩进 "@stylistic/semi": ["error", "always"], // 强制分号 "@stylistic/comma-dangle": ["error", "always-multiline"], // 多行末尾逗号 "@stylistic/brace-style": ["error", "1tbs"], // 大括号风格 // Import 相关规则 "import/no-duplicates": "error", "import/no-self-import": "error", "import/no-useless-path-segments": "error", // 企业级最佳实践 "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", }], "@typescript-eslint/no-non-null-assertion": "warn", "no-console": "warn", }, settings: { "import/resolver": { "typescript": { "alwaysTryTypes": true, "project": "./tsconfig.json", }, "node": { "extensions": [".js", ".jsx", ".ts", ".tsx"], }, }, }, }, // 特定于主进程的配置 { files: ["src/main/**/*.{ts,js}"], languageOptions: { globals: { ...globals.node, }, }, rules: { "no-console": "off", // 主进程允许使用 console }, }, // 特定于渲染进程的配置 { files: ["src/renderer*/**/*.{ts,tsx,js,jsx}"], languageOptions: { globals: { ...globals.browser, }, }, }, // 配置文件和脚本的特殊规则 { files: ["*.config.{js,ts,mjs}", "scripts/**/*.{js,ts}"], rules: { "@typescript-eslint/no-var-requires": "off", "no-console": "off", }, }, // 忽略文件 { ignores: [ "node_modules/**", "dist/**", ".webpack/**", "out/**", "release/**", "**/*.d.ts", ], }, ]; ================================================ FILE: forge.config.ts ================================================ import type { ForgeConfig } from "@electron-forge/shared-types"; import { MakerZIP } from "@electron-forge/maker-zip"; import { MakerDeb } from "@electron-forge/maker-deb"; import { MakerDMG } from "@electron-forge/maker-dmg"; import { WebpackPlugin } from "@electron-forge/plugin-webpack"; import { mainConfig } from "./config/webpack.main.config"; import { rendererConfig } from "./config/webpack.renderer.config"; import path from "path"; const config: ForgeConfig = { packagerConfig: { appBundleId: "fun.upup.musicfree", icon: path.resolve(__dirname, "res/logo"), executableName: "MusicFree", extraResource: [path.resolve(__dirname, "res")], protocols: [ { name: "MusicFree", schemes: ["musicfree"], }, ], }, rebuildConfig: {}, makers: [ // new MakerSquirrel({ // exe: "MusicFree", // setupIcon: path.resolve(__dirname, "resources/logo.ico"), // setupMsi: "MusicFreeInstaller", // }), new MakerZIP({}, ["darwin"]), new MakerDMG( { // background format: "ULFO", }, ["darwin"] ), // new MakerRpm({}), new MakerDeb({ options: { name: "MusicFree", bin: "MusicFree", mimeType: ["x-scheme-handler/musicfree"], }, }), ], plugins: [ new WebpackPlugin({ devContentSecurityPolicy: `default-src * self blob: data: gap: file:; style-src * self 'unsafe-inline' blob: data: gap: file:; script-src * 'self' 'unsafe-eval' 'unsafe-inline' blob: data: gap: file:; object-src * 'self' blob: data: gap:; img-src * self 'unsafe-inline' blob: data: gap: file:; connect-src self * 'unsafe-inline' blob: data: gap:; frame-src * self blob: data: gap:;`, mainConfig, renderer: { config: rendererConfig, entryPoints: [ { html: "./src/renderer/document/index.html", js: "./src/renderer/document/index.tsx", name: "main_window", preload: { js: "./src/preload/index.ts", }, }, { html: "./src/renderer-lrc/document/index.html", js: "./src/renderer-lrc/document/index.tsx", name: "lrc_window", preload: { js: "./src/preload/extension.ts", }, }, { html: "./src/renderer-minimode/document/index.html", js: "./src/renderer-minimode/document/index.tsx", name: "minimode_window", preload: { js: "./src/preload/extension.ts", }, }, /** webworkers */ { js: "./src/webworkers/downloader.ts", name: "worker_downloader", nodeIntegration: true, }, { js: "./src/webworkers/local-file-watcher.ts", name: "local_file_watcher", nodeIntegration: true, }, { js: "./src/webworkers/db-worker.ts", name: "db", nodeIntegration: true, } ], }, }), { name: "@timfish/forge-externals-plugin", config: { externals: ["sharp"], includeDeps: true, }, }, ], }; export default config; ================================================ FILE: package.json ================================================ { "name": "musicfree-desktop", "productName": "MusicFree", "version": "0.0.8", "description": "一个插件化的音乐播放器", "main": ".webpack/main", "scripts": { "start": "electron-forge start", "dev": "electron-forge start --inspect-electron", "package": "electron-forge package", "make": "electron-forge make", "publish": "electron-forge publish", "lint": "eslint ./src --fix", "lint-staged": "lint-staged", "prepare": "husky install" }, "keywords": [], "author": { "name": "猫头猫", "email": "lhx_xjtu@163.com" }, "license": "GPL", "lint-staged": { "src/**/*.{ts,tsx,js}": [ "npm run lint", "git add ." ] }, "devDependencies": { "@babel/core": "^7.22.1", "@babel/preset-react": "^7.22.0", "@electron-forge/cli": "6.4.1", "@electron-forge/maker-deb": "6.4.1", "@electron-forge/maker-dmg": "6.4.1", "@electron-forge/maker-rpm": "6.4.1", "@electron-forge/maker-squirrel": "6.4.1", "@electron-forge/maker-zip": "6.4.1", "@electron-forge/plugin-webpack": "6.4.1", "@eslint/js": "^9.15.0", "@larksuiteoapi/node-sdk": "^1.50.1", "@stylistic/eslint-plugin": "^5.0.0", "@svgr/webpack": "^8.1.0", "@timfish/forge-externals-plugin": "^0.2.1", "@types/better-sqlite3": "^7.6.13", "@types/crypto-js": "^4.2.2", "@types/he": "^1.2.3", "@types/lodash.debounce": "^4.0.9", "@types/lodash.shuffle": "^4.2.9", "@types/lodash.throttle": "^4.1.9", "@types/object-path": "^0.11.4", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", "@types/unzipper": "^0.10.9", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vercel/webpack-asset-relocator-loader": "^1.7.3", "babel-loader": "^9.1.3", "cross-env": "^7.0.3", "css-loader": "^6.11.0", "electron": "^25.3.0", "eslint": "^9.15.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.3.0", "globals": "^15.12.0", "husky": "^9.0.11", "lint-staged": "^15.2.2", "node-loader": "^2.0.0", "sass": "^1.83.0", "sass-loader": "^16.0.4", "style-loader": "^4.0.0", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "~5.0.4", "typescript-eslint": "^8.15.0" }, "dependencies": { "@headlessui/react": "^1.7.15", "@tanstack/react-table": "^8.17.3", "animate.css": "^4.1.1", "axios": "1.7.4", "better-sqlite3": "^12.1.1", "big-integer": "^1.6.52", "cheerio": "^1.0.0-rc.12", "chokidar": "^3.6.0", "color": "^4.2.3", "comlink": "^4.4.2", "compare-versions": "^6.1.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.11", "dexie": "^3.2.4", "electron-log": "^5.2.0", "eventemitter3": "^5.0.1", "he": "^1.2.0", "hls.js": "^1.5.8", "hotkeys-js": "^3.13.7", "https-proxy-agent": "^7.0.4", "i18next": "^22.5.1", "iconv-lite": "^0.6.3", "immer": "^10.1.1", "jschardet": "^3.1.3", "lodash.shuffle": "^4.2.0", "lodash.throttle": "^4.1.1", "lru-cache": "^10.2.2", "music-metadata": "^8.3.0", "nanoid": "^4.0.2", "object-path": "^0.11.8", "p-queue": "^7.4.1", "qs": "^6.12.1", "rc-slider": "^10.6.2", "react": "^18.3.1", "react-colorful": "^5.6.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", "react-i18next": "^12.3.1", "react-router-dom": "^6.23.1", "react-toastify": "^9.1.3", "react-tooltip": "^5.26.4", "rimraf": "^5.0.7", "sharp": "^0.32.6", "socket.io": "^4.7.5", "unzipper": "^0.11.6", "webdav": "^5.6.0" } } ================================================ FILE: release/build-windows.iss ================================================ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "MusicFree" #ifndef MyAppVersion #define MyAppVersion "0.0.0-alpha.0" #endif #define MyAppPublisher "maotoumao" #define MyAppURL "https://musicfree.catcat.work" #define MyAppExeName "MusicFree.exe" #ifndef MyAppId #define MyAppId #endif [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{{#MyAppId}} AppName={#MyAppName} AppVersion={#MyAppVersion} ;AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\{#MyAppName} DisableProgramGroupPage=yes ; Uncomment the following line to run in non administrative install mode (install for current user only.) ;PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog OutputDir=..\out OutputBaseFilename=MusicFreeSetup SetupIconFile=..\res\logo.ico Compression=lzma SolidCompression=yes WizardStyle=modern [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] Source: "..\out\MusicFree-win32-x64\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "..\out\MusicFree-win32-x64\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent ================================================ FILE: release/version.json ================================================ { "version": "0.0.8", "changeLog": [ "1. 【修复】修复了一些可能导致白屏的问题" ], "download": [ "https://r0rvr854dd1.feishu.cn/drive/folder/IrVEfD67KlWZGkdqwjecLHFNnBb?from=from_copylink" ] } ================================================ FILE: res/.service/request-forwarder.js ================================================ const http = require("http"); const https = require("https"); const defaultPort = 52735; const maxRetries = 20; let retryCount = 0; function forwardRequest(clientRes, url, method, headers) { // 确保 host 正确 let host = headers?.host; if (!host || host.includes("localhost") || host.includes("127.0.0.1")) { // 如果没有提供 host,且是本地请求,则使用目标 URL 的主机名 host = new URL(url).host; } const options = { method: method, headers: { ...(headers || {}), host, // 确保目标主机名正确 }, }; const protocol = url.startsWith("https") ? https : http; const req = protocol.request(url, options, (targetRes) => { // 将目标响应的状态码和头部转发到客户端 clientRes.writeHead(targetRes.statusCode, targetRes.headers); // 将目标响应的数据流转发到客户端 targetRes.pipe(clientRes, { end: true, }); }); req.on("error", (error) => { console.error("Error forwarding request:", error); clientRes.writeHead(500, { "Content-Type": "text/plain" }); clientRes.end("Internal Server Error"); }); // 结束目标请求 req.end(); } function safeParse(data) { try { return JSON.parse(data) || {}; } catch (e) { return {}; } } function startServer(port) { // 创建一个 HTTP 服务器 const server = http.createServer((req, res) => { if (req.method !== "GET") { res.writeHead(405, { "Content-Type": "text/plain" }); return res.end("Only GET requests are allowed"); } if (req.url === "/heartbeat") { res.writeHead(200, { "Content-Type": "text/plain" }); return res.end("OK"); } const query = new URLSearchParams(req.url.slice(1)); const url = query.get("url"); const method = query.get("method") || "GET"; // 默认使用 GET 方法 const headers = safeParse(query.get("headers")); res.setHeader("Access-Control-Allow-Origin", "*"); // 允许所有源 res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); // 允许的方法 if (!url) { res.writeHead(400, { "Content-Type": "text/plain" }); return res.end("Bad Request: Missing URL"); } forwardRequest(res, url, method, { ...(req.headers || {}), ...(headers || {}) }); }); server.listen(port, () => { process.send?.({ type: "port", port }); console.log(`Proxy server is running on http://localhost:${port}`); }); server.on("error", (err) => { console.error("Server error:", err); if (retryCount < maxRetries) { retryCount++; const newPort = port + 1; // 尝试下一个端口 console.log(`Retrying on port: ${newPort} (attempt ${retryCount})`); startServer(newPort); } else { process.send?.({ type: "error", error: "Max retries reached" }); } }) } startServer(defaultPort); ================================================ FILE: res/lang/en-US.json ================================================ { "common": { "cancel": "Cancel", "confirm": "Confirm", "download": "Download", "downloading": "Downloading", "downloaded": "Downloaded", "remove": "Remove", "delete": "Delete", "default": "Default", "version_code": "Version Code", "operation": "Operation", "update": "Update", "uninstall": "Uninstall", "install": "Install", "about": "About", "exit": "Exit", "edit": "Edit", "undo": "Undo", "redo": "Redo", "cut": "Cut", "copy": "Copy", "paste": "Paste", "select_all": "Select All", "loading": "Loading", "create": "Create", "add": "Add", "save": "Save", "clear": "Clear", "open": "Open", "status": "Status" }, "media": { "unknown_title": "Untitled", "unknown_artist": "Unknown Artist", "unknown_album": "Unknown Album", "default_favorite_sheet_name": "Favorites", "playlist": "Playlist", "media_type_music": "Music", "media_type_album": "Album", "media_type_artist": "Artist", "media_type_sheet": "Playlist", "media_type_lyric": "Lyric", "media_type_comment": "Comment", "media_title": "Title", "media_platform": "Source", "media_duration": "Duration", "media_create_at": "Created At", "media_play_count": "Play Count", "media_music_count": "Song Count", "media_description": "Description", "music_state_pause": "Pause", "music_state_play": "Play", "music_state_play_or_pause": "Play/Pause", "music_quality_low": "Low Quality", "music_quality_standard": "Standard Quality", "music_quality_high": "High Quality", "music_quality_super": "Super Quality", "music_repeat_mode": "Repeat Mode", "music_repeat_mode_loop": "Single Loop", "music_repeat_mode_queue": "Queue Loop", "music_repeat_mode_shuffle": "Shuffle Play" }, "plugin": { "prop_user_variable": "User Variable", "method_search": "Search", "method_import_music_item": "Import Song", "method_import_music_sheet": "Import Playlist", "method_get_top_lists": "Top Charts", "info_hint_you_have_no_plugin": "You have no plugins installed", "info_hint_you_have_no_plugin_with_supported_method": "You have no plugins installed that support {{supportMethod}} feature", "info_hint_install_plugin_before_use": "Go to Plugin Management to install plugins~" }, "download_page": { "waiting": "Waiting...", "failed": "Download Failed" }, "plugin_management_page": { "plugin_management": "Plugin Management", "choose_plugin": "Choose Plugin", "install": "Install", "musicfree_plugin": "MusicFree Plugin", "install_successfully": "Plugin Installed Successfully", "install_failed": "Install Failed", "invalid_plugin": "Invalid Plugin", "install_from_local_file": "Install from Local File", "install_from_network": "Install from Network", "install_plugin_from_network": "Install Plugin from Network", "installing": "Installing", "info_hint_install_placeholder": "Please enter the plugin source URL (link ends with .json or .js)", "error_hint_plugin_should_end_with_js_or_json": "Plugin URL must end with .json or .js", "info_hint_install_plugin": "Plugins must comply with the MusicFree plugin protocol. For details, visit the official website", "subscription_setting": "Subscription Settings", "update_subscription": "Update Subscription", "update_successfully": "Update Successful", "no_subscription": "No Current Subscriptions", "uninstall": "Uninstall", "uninstall_plugin": "Uninstall Plugin", "confirm_text_uninstall_plugin": "Confirm to uninstall plugin {{plugin}}?", "uninstall_successfully": "Uninstalled {{plugin}} successfully", "uninstall_failed": "Uninstall Failed", "toast_plugin_is_latest": "Plugin {{plugin}} is up to date", "update_failed": "Update Failed", "update": "Update", "importing_media": "Importing", "placeholder_import_music_item": "Enter {{plugin}} song link", "import_failed": "Import Failed", "placeholder_import_music_sheet": "Enter {{plugin}} playlist link" }, "local_music_page": { "local_music": "Local Music", "auto_scan": "Auto Scan", "search_local_music": "Search Local Music", "list_view": "List View", "artist_view": "Artist View", "album_view": "Album View", "folder_view": "Folder View", "total_music_num": "Total {{number}} songs" }, "music_list_context_menu": { "next_play": "Play Next", "add_to_my_sheets": "Add to Playlist", "remove_from_sheet": "Remove from Playlist", "delete_local_download": "Delete Local Download", "reveal_local_music_in_file_explorer": "Show in File Explorer", "reveal_local_music_in_file_explorer_fail": "Open Failed: ", "delete_local_downloaded_songs_success": "Deleted {{musicNums}} local songs", "delete_local_downloaded_song_success": "Deleted local song [{{songName}}]" }, "search_result_page": { "search_result_title": "Search Results for" }, "side_bar": { "toplist": "Top Charts", "recommend_sheets": "Popular Playlists", "download_management": "Download Management", "local_music": "Local Music", "plugin_management": "Plugin Management", "my_sheets": "My Playlists", "create_local_sheet": "Create Playlist", "starred_sheets": "Favorites", "delete_sheet": "Delete", "rename_sheet": "Rename", "unstar_sheet": "Unstar", "recently_play": "Recently Play" }, "app_header": { "nav_back": "Back", "nav_forward": "Forward", "search_placeholder": "Enter search content here", "search_history": "Search History", "settings": "Settings", "minimize": "Minimize", "minimode": "Minimode", "exit": "Exit", "theme": "Theme" }, "music_bar": { "open_music_detail_page": "Open Song Details", "close_music_detail_page": "Close Song Details", "previous_music": "Previous", "next_music": "Next", "mute": "Mute", "unmute": "Unmute", "playback_speed": "Playback Speed", "choose_music_quality": "Change Quality", "only_set_for_current_music": "Only for Current Song", "desktop_lyric": "Desktop Lyric" }, "music_detail": { "search_lyric": "Search Lyric", "no_lyric": "No Lyrics", "lyric_ctx_download_lyric": "Download Lyric", "lyric_ctx_download_lyric_lrc": "Download Lyric (.lrc)", "lyric_ctx_download_lyric_txt": "Download Lyric (.txt)", "lyric_ctx_download_success": "Download Successful", "lyric_ctx_download_fail": "Download Failed", "lyric_ctx_set_font_size": "Set Font Size", "link_media_lyric": "Link Lyric", "media_lyric_linked": "Lyric Linked: ", "unlink_media_lyric": "Unlink Lyric", "toast_media_lyric_unlinked": "Lyric Unlinked", "translation": "Translation", "show_translation": "Show Translation", "hide_translation": "Hide Translation" }, "bottom_loading_state": { "reached_end": "~~~ End ~~~", "loading": "Loading...", "load_more": "Load More" }, "empty": { "hint_empty": "Nothing here~~~" }, "modal": { "add_to_my_sheets": "Add to Playlist", "total_music_num": "Total {{number}} songs", "create_local_sheet": "Create Playlist", "create_local_sheet_placeholder": "Enter new playlist name", "exit_confirm": "Confirm Exit?", "plugin_subscription": "Plugin Subscription", "subscription_remarks": "Remarks: ", "subscription_links": "Links: ", "subscription_save_success": "Subscription Address Saved", "search_lyric": "Search Lyric", "search_lyric_result_empty": "No Search Results", "media_lyric_linked": "Lyric Linked~", "media_lyric_link_failed": "Link Lyric Failed:", "new_version_found": "New Version Found", "latest_version": "Latest Version: ", "current_version": "Current Version: ", "skip_this_version": "Skip This Version", "scan_local_music": "Scan Local Music", "scan_local_music_hint": "Automatically scan selected folders (real-time sync with file changes)", "add_folder": "Add Folder" }, "panel": { "play_list_song_num": "Playlist ({{number}} songs)", "user_variable": "User Variable", "user_variable_setting_success": "Setting Successful~" }, "music_sheet_like_view": { "play_all": "Play All", "add_to_sheet": "Add to Playlist", "star": "Star" }, "settings": { "choose_path": "Choose Path", "change_path": "Change Path", "folder_not_exist": "Folder Not Exist", "open_folder": "Open Folder", "section_name": { "normal": "General", "play_music": "Playback", "download": "Download", "lyric": "Lyric", "plugin": "Plugin", "theme": "Theme", "short_cut": "Shortcuts", "network": "Network", "backup": "Backup & Restore", "about": "About MusicFree" }, "normal": { "check_update": "Check for updates on startup", "auto_load_more": "(Playlist Page) Automatically load more when scrolling to the bottom", "close_behavior": "When clicking exit button", "exit_app": "Exit App", "minimize": "Minimize to tray", "taskbar_thumb": "Taskbar thumbnail style (effective after restart)", "current_artwork": "Current song artwork", "main_window": "Main window interface", "max_history_length": "Maximum search history entries", "music_list_hide_columns": "Hide columns in song list", "languages": "Languages", "toast_switch_language_fail": "Switching language failed" }, "play_music": { "case_sensitive_in_search": "Case-sensitive search in playlist", "default_play_quality": "Default playback quality", "when_quality_missing": "When playback quality is missing", "play_lower_quality_version": "Play lower quality version", "play_higher_quality_version": "Play higher quality version", "play_skip_quality_version": "Do not look for other quality versions", "when_play_error": "When playback error occurs", "pause": "Pause", "skip_to_next": "Automatically play next song", "double_click_music_list": "When double-clicking the music list", "add_music_to_playlist": "Add the selected song to the playback queue", "replace_playlist_with_musiclist": "Replace playback queue with current music list", "audio_output_device": "Audio output device", "when_device_removed": "When device is removed", "continue_playing": "Continue playing" }, "download": { "download_folder": "Download Directory", "max_concurrency": "Maximum concurrent downloads", "default_download_quality": "Default download quality", "when_quality_missing": "When download quality is missing", "download_lower_quality_version": "Download lower quality version", "download_higher_quality_version": "Download higher quality version" }, "lyric": { "enable_status_bar_lyric": "Enable status bar lyric", "enable_desktop_lyric": "Enable desktop lyric", "lock_desktop_lyric": "Lock desktop lyric", "font": "Font", "font_size": "Font Size", "font_color": "Font Color", "stroke_color": "Stroke Color" }, "plugin": { "auto_update_plugin": "Automatically update plugins on startup", "not_check_plugin_version": "Do not check plugin version when installing" }, "short_cut": { "enable_local": "Enable in-app shortcuts", "enable_global": "Enable global shortcuts", "ability": "Function", "local_short_cut": "App Shortcuts", "global_short_cut": "Global Shortcuts", "play/pause": "Play/Pause", "skip-next": "Play Next", "skip-previous": "Play Previous", "volume-up": "Volume Up", "volume-down": "Volume Down", "toggle-desktop-lyric": "Toggle Desktop Lyric", "like/dislike": "Like/Dislike Current Song", "no_short_cut": "None", "toggle-main-window-visible": "Show/Hide Main Window" }, "network": { "host": "Host", "port": "Port", "username": "Username", "password": "Password", "local_cache": "Local Cache: {{cacheSize}}", "clear_cache": "Clear Cache", "enable_network_proxy": "Enable Network Proxy" }, "backup": { "resume_mode_append": "Append to existing playlist", "resume_mode_overwrite": "Overwrite existing playlist", "backup_by_file": "File Backup", "musicfree_backup_file": "MusicFree Backup File", "backup_to": "Backup to...", "backup_by_webdav": "WebDAV Backup", "backup_success": "Backup Successful~", "backup_fail": "Backup Failed: {{reason}}", "resume_success": "Restore Successful~", "resume_fail": "Restore Failed: {{reason}}", "backup_music_sheet": "Backup Playlist", "resume_music_sheet": "Restore Playlist", "webdav_server_url": "URL", "username": "Username", "password": "Password", "webdav_data_not_complete": "URL, username, and password cannot be empty", "webdav_backup_file_not_exist": "Backup file does not exist" }, "about": { "current_version": "Current Version: {{version}}", "already_latest": "Already up to date!", "check_update": "Check for Update", "software_author": "Software Author: ", "open_source_declaration": "Source Code: Software is open-source under AGPL3.0 license. Github Link Gitee Link", "official_site": "Official Website", "mobile_version": "Mobile Version" } }, "main": { "previous_music": "Previous Song", "next_music": "Next Song", "close_desktop_lyric": "Close Desktop Lyrics", "open_desktop_lyric": "Open Desktop Lyrics", "unlock_desktop_lyric": "Unlock Desktop Lyrics", "lock_desktop_lyric": "Lock Desktop Lyrics", "no_playing_music": "No Music Playing" }, "theme": { "tab_local": "Local Theme", "tab_remote": "Theme Marketplace", "download_and_use": "Download and Use", "use_theme": "Use Theme", "install_theme": "Install Theme", "update_theme": "Update Theme", "uninstall_theme": "Uninstall Theme", "musicfree_theme": "MusicFree Theme", "all_files": "All Files", "install_theme_success": "Successfully installed theme {{name}}~", "install_theme_fail": "Failed to install theme: {{reason}}", "uninstall_theme_success": "Successfully uninstalled theme {{name}}~", "uninstall_theme_fail": "Failed to uninstall theme: {{reason}}", "how_to_submit_new_theme": "💡How to submit a new theme: The themes in the theme marketplace are synchronized with the MusicFreeThemePacks repository. If you need to submit a new theme, please make a pull request directly.", "load_remote_theme_error": "An error occurred...", "invalid_theme": "Invalid theme: {{reason}}" } } ================================================ FILE: res/lang/zh-CN.json ================================================ { "common": { "cancel": "取消", "confirm": "确认", "download": "下载", "downloading": "下载中", "downloaded": "已下载", "remove": "删除", "delete": "删除", "default": "默认", "version_code": "版本号", "operation": "操作", "update": "更新", "uninstall": "卸载", "install": "安装", "about": "关于", "exit": "退出", "edit": "编辑", "undo": "撤销", "redo": "恢复", "cut": "剪切", "copy": "复制", "paste": "粘贴", "select_all": "全选", "loading": "加载中", "create": "创建", "add": "添加", "save": "保存", "clear": "清空", "open": "打开", "status": "状态" }, "media": { "unknown_title": "未命名", "unknown_artist": "未知作者", "unknown_album": "未知专辑", "default_favorite_sheet_name": "我喜欢", "playlist": "播放列表", "media_type_music": "音乐", "media_type_album": "专辑", "media_type_artist": "作者", "media_type_sheet": "歌单", "media_type_lyric": "歌词", "media_type_comment": "评论", "media_title": "标题", "media_platform": "来源", "media_duration": "时长", "media_create_at": "创建时间", "media_play_count": "播放数", "media_music_count": "歌曲数", "media_description": "简介", "music_state_pause": "暂停", "music_state_play": "播放", "music_state_play_or_pause": "播放/暂停", "music_quality_low": "低音质", "music_quality_standard": "标准音质", "music_quality_high": "高音质", "music_quality_super": "超高音质", "music_repeat_mode": "播放模式", "music_repeat_mode_loop": "单曲循环", "music_repeat_mode_queue": "列表循环", "music_repeat_mode_shuffle": "随机播放" }, "plugin": { "prop_user_variable": "用户变量", "method_search": "搜索", "method_import_music_item": "导入单曲", "method_import_music_sheet": "导入歌单", "method_get_top_lists": "排行榜", "info_hint_you_have_no_plugin": "你还没有安装插件", "info_hint_you_have_no_plugin_with_supported_method": "你还没有安装支持 {{supportMethod}} 功能的插件", "info_hint_install_plugin_before_use": "先去插件管理 安装插件吧~" }, "download_page": { "waiting": "等待中...", "failed": "下载失败" }, "plugin_management_page": { "plugin_management": "插件管理", "choose_plugin": "选择插件", "install": "安装", "musicfree_plugin": "MusicFree插件", "install_successfully": "插件安装成功", "install_failed": "安装失败", "invalid_plugin": "无效插件", "install_from_local_file": "从本地文件安装", "install_from_network": "从网络安装", "install_plugin_from_network": "从网络安装插件", "installing": "正在安装", "info_hint_install_placeholder": "请输入插件源地址(链接以json或js结尾)", "error_hint_plugin_should_end_with_js_or_json": "插件链接需要以json或者js结尾", "info_hint_install_plugin": "插件需要满足 MusicFree 特定的插件协议,具体可在官方网站中查看", "subscription_setting": "订阅设置", "update_subscription": "更新订阅", "update_successfully": "更新成功", "no_subscription": "当前无订阅", "uninstall": "卸载", "uninstall_plugin": "卸载插件", "confirm_text_uninstall_plugin": "确认卸载插件 {{plugin}} 吗?", "uninstall_successfully": "已卸载 {{plugin}}", "uninstall_failed": "卸载失败", "toast_plugin_is_latest": "插件 {{plugin}} 已更新到最新版本", "update_failed": "更新失败", "update": "更新", "importing_media": "正在导入中", "placeholder_import_music_item": "输入 {{plugin}} 单曲链接", "import_failed": "导入失败", "placeholder_import_music_sheet": "输入 {{plugin}} 歌单链接" }, "local_music_page": { "local_music": "本地音乐", "auto_scan": "自动扫描", "search_local_music": "搜索本地音乐", "list_view": "列表视图", "artist_view": "作者视图", "album_view": "专辑视图", "folder_view": "文件夹视图", "total_music_num": "共 {{number}} 首" }, "music_list_context_menu": { "next_play": "下一首播放", "add_to_my_sheets": "添加到歌单", "remove_from_sheet": "从歌单内删除", "delete_local_download": "删除本地下载", "reveal_local_music_in_file_explorer": "打开歌曲所在文件夹", "reveal_local_music_in_file_explorer_fail": "打开失败: ", "delete_local_downloaded_songs_success": "已删除 {{musicNums}} 首本地歌曲", "delete_local_downloaded_song_success": "已删除本地歌曲 [{{songName}}]" }, "search_result_page": { "search_result_title": "的搜索结果" }, "side_bar": { "toplist": "排行榜", "recommend_sheets": "热门歌单", "download_management": "下载管理", "local_music": "本地音乐", "plugin_management": "插件管理", "my_sheets": "我的歌单", "create_local_sheet": "新建歌单", "starred_sheets": "我的收藏", "delete_sheet": "删除歌单", "rename_sheet": "重命名歌单", "unstar_sheet": "取消收藏", "recently_play": "最近播放" }, "app_header": { "nav_back": "后退", "nav_forward": "前进", "search_placeholder": "在这里输入搜索内容", "search_history": "搜索历史", "settings": "设置", "minimize": "最小化", "minimode": "迷你模式", "exit": "退出", "theme": "主题" }, "music_bar": { "open_music_detail_page": "打开歌曲详情页", "close_music_detail_page": "关闭歌曲详情页", "previous_music": "上一首", "next_music": "下一首", "mute": "静音", "unmute": "恢复音量", "playback_speed": "倍速播放", "choose_music_quality": "切换音质", "only_set_for_current_music": "仅设置当前歌曲", "desktop_lyric": "桌面歌词" }, "music_detail": { "search_lyric": "搜索歌词", "no_lyric": "暂无歌词", "lyric_ctx_download_lyric": "下载歌词", "lyric_ctx_download_lyric_lrc": "下载歌词 (.lrc)", "lyric_ctx_download_lyric_txt": "下载歌词 (.txt)", "lyric_ctx_download_success": "下载成功", "lyric_ctx_download_fail": "下载失败", "lyric_ctx_set_font_size": "设置字号", "link_media_lyric": "关联歌词", "media_lyric_linked": "已关联歌词: ", "unlink_media_lyric": "取消关联歌词", "toast_media_lyric_unlinked": "已取消关联歌词", "translation": "翻译", "show_translation": "显示翻译", "hide_translation": "隐藏翻译" }, "bottom_loading_state": { "reached_end": "~~~ 到底啦 ~~~", "loading": "加载中...", "load_more": "加载更多" }, "empty": { "hint_empty": "什么都没有呀~~~" }, "modal": { "add_to_my_sheets": "添加到歌单", "total_music_num": "共 {{number}} 首", "create_local_sheet": "新建歌单", "create_local_sheet_placeholder": "请输入新建歌单名称", "exit_confirm": "确认退出?", "plugin_subscription": "插件订阅", "subscription_remarks": "备注: ", "subscription_links": "链接: ", "subscription_save_success": "已保存订阅地址", "search_lyric": "搜索歌词", "search_lyric_result_empty": "搜索结果为空", "media_lyric_linked": "已关联歌词~", "media_lyric_link_failed": "关联歌词失败:", "new_version_found": "发现新版本", "latest_version": "最新版本: ", "current_version": "当前版本: ", "skip_this_version": "跳过此版本", "scan_local_music": "扫描本地音乐", "scan_local_music_hint": "将自动扫描勾选的文件夹 (文件增删实时同步)", "add_folder": "添加文件夹" }, "panel": { "play_list_song_num": "播放列表 ({{number}}首)", "user_variable": "用户变量", "user_variable_setting_success": "设置成功~" }, "music_sheet_like_view": { "play_all": "播放", "add_to_sheet": "添加", "star": "收藏" }, "settings": { "choose_path": "选择路径", "change_path": "更改路径", "folder_not_exist": "文件夹不存在", "open_folder": "打开文件夹", "section_name": { "normal": "常规", "play_music": "播放", "download": "下载", "lyric": "歌词", "plugin": "插件", "theme": "主题", "short_cut": "快捷键", "network": "网络", "backup": "备份与恢复", "about": "关于 MusicFree" }, "normal": { "check_update": "应用启动时检测软件版本更新", "auto_load_more": "(歌单页) 滑动到页面底部时自动加载更多", "close_behavior": "单击退出按钮时", "exit_app": "退出应用", "minimize": "最小化到托盘", "taskbar_thumb": "任务栏缩略图样式(重启应用后生效)", "current_artwork": "当前播放歌曲的封面", "main_window": "主窗口界面", "max_history_length": "搜索历史记录最多保存条数", "music_list_hide_columns": "歌曲列表隐藏列", "languages": "语言", "toast_switch_language_fail": "切换语言失败" }, "play_music": { "case_sensitive_in_search": "歌单内搜索时区分大小写", "default_play_quality": "默认播放音质", "when_quality_missing": "播放音质缺失时", "play_lower_quality_version": "播放更低音质", "play_higher_quality_version": "播放更高音质", "play_skip_quality_version": "不寻找其他音质版本", "when_play_error": "播放失败时", "pause": "暂停播放", "skip_to_next": "自动播放下一首", "double_click_music_list": "双击音乐列表时", "add_music_to_playlist": "将目标单曲添加到播放队列", "replace_playlist_with_musiclist": "使用当前音乐列表替换播放队列", "audio_output_device": "音频输出设备", "when_device_removed": "音频设备移除时", "continue_playing": "继续播放" }, "download": { "download_folder": "下载目录", "max_concurrency": "最多同时下载歌曲数", "default_download_quality": "默认下载音质", "when_quality_missing": "下载音质缺失时", "download_lower_quality_version": "下载更低音质", "download_higher_quality_version": "下载更高音质" }, "lyric": { "enable_status_bar_lyric": "启用状态栏歌词", "enable_desktop_lyric": "启用桌面歌词", "lock_desktop_lyric": "锁定桌面歌词", "font": "字体", "font_size": "字体大小", "font_color": "字体颜色", "stroke_color": "描边颜色" }, "plugin": { "auto_update_plugin": "打开软件时自动更新插件", "not_check_plugin_version": "安装插件时不校验版本" }, "short_cut": { "enable_local": "启用软件内快捷键", "enable_global": "启用全局快捷键", "ability": "功能", "local_short_cut": "软件快捷键", "global_short_cut": "全局快捷键", "play/pause": "播放/暂停", "skip-next": "播放下一首", "skip-previous": "播放上一首", "volume-up": "增加音量", "volume-down": "降低音量", "toggle-desktop-lyric": "打开/关闭桌面歌词", "like/dislike": "喜欢/不喜欢当前歌曲", "no_short_cut": "空", "toggle-main-window-visible": "显示/隐藏主窗口" }, "network": { "host": "主机", "port": "端口", "username": "账号", "password": "密码", "local_cache": "本地缓存:{{cacheSize}}", "clear_cache": "清空缓存", "enable_network_proxy": "启用网络代理" }, "backup": { "resume_mode_append": "追加到已有歌单末尾", "resume_mode_overwrite": "覆盖已有歌单", "backup_by_file": "文件备份", "musicfree_backup_file": "MusicFree 备份文件", "backup_to": "备份到...", "backup_by_webdav": "WebDAV 备份", "backup_success": "备份成功~", "backup_fail": "备份失败:{{reason}}", "resume_success": "恢复成功~", "resume_fail": "恢复失败:{{reason}}", "backup_music_sheet": "备份歌单", "resume_music_sheet": "恢复歌单", "webdav_server_url": "URL", "username": "账号", "password": "密码", "webdav_data_not_complete": "URL、账号、密码不可为空", "webdav_backup_file_not_exist": "备份文件不存在" }, "about": { "current_version": "当前版本: {{version}}", "already_latest": "当前已是最新版本!", "check_update": "检查更新", "software_author": "软件作者: ", "open_source_declaration": "源代码: 软件基于 AGPL3.0 协议开源. Github地址 Gitee地址", "official_site": "软件官网", "mobile_version": "移动版" } }, "main": { "previous_music": "上一首", "next_music": "下一首", "close_desktop_lyric": "关闭桌面歌词", "open_desktop_lyric": "开启桌面歌词", "unlock_desktop_lyric": "解锁桌面歌词", "lock_desktop_lyric": "锁定桌面歌词", "no_playing_music": "当前无正在播放的音乐" }, "theme": { "tab_local": "本地主题", "tab_remote": "主题市场", "download_and_use": "下载并使用", "use_theme": "使用主题", "install_theme": "安装主题", "update_theme": "更新主题", "uninstall_theme": "卸载主题", "musicfree_theme": "MusicFree 主题", "all_files": "全部文件", "install_theme_success": "安装主题{{name}}成功~", "install_theme_fail": "安装主题失败: {{reason}}", "uninstall_theme_success": "卸载主题{{name}}成功~", "uninstall_theme_fail": "卸载主题失败: {{reason}}", "how_to_submit_new_theme": "💡如何提交新主题: 主题市场中的主题与 MusicFreeThemePacks 仓库同步,如果需要提交新主题请直接提PR。", "load_remote_theme_error": "出错啦...", "invalid_theme": "主题无效: {{reason}}" } } ================================================ FILE: res/lang/zh-TW.json ================================================ { "common": { "cancel": "取消", "confirm": "確認", "download": "下載", "downloading": "下載中", "downloaded": "已下載", "remove": "移除", "delete": "刪除", "default": "預設", "version_code": "版本號", "operation": "操作", "update": "更新", "uninstall": "卸載", "install": "安裝", "about": "關於", "exit": "退出", "edit": "編輯", "undo": "撤銷", "redo": "重做", "cut": "剪下", "copy": "複製", "paste": "貼上", "select_all": "全選", "loading": "加載中", "create": "創建", "add": "新增", "save": "保存", "clear": "清除", "open": "打開", "status": "狀態" }, "media": { "unknown_title": "無標題", "unknown_artist": "未知藝術家", "unknown_album": "未知專輯", "default_favorite_sheet_name": "喜愛", "playlist": "播放清單", "media_type_music": "音樂", "media_type_album": "專輯", "media_type_artist": "藝術家", "media_type_sheet": "歌單", "media_type_lyric": "歌詞", "media_type_comment": "評論", "media_title": "標題", "media_platform": "來源", "media_duration": "時長", "media_create_at": "創建時間", "media_play_count": "播放次數", "media_music_count": "歌曲數量", "media_description": "描述", "music_state_pause": "暫停", "music_state_play": "播放", "music_state_play_or_pause": "播放/暫停", "music_quality_low": "低音質", "music_quality_standard": "標準音質", "music_quality_high": "高音質", "music_quality_super": "超高音質", "music_repeat_mode": "重複模式", "music_repeat_mode_loop": "單曲重複", "music_repeat_mode_queue": "列表重複", "music_repeat_mode_shuffle": "隨機播放" }, "plugin": { "prop_user_variable": "用戶變量", "method_search": "搜尋", "method_import_music_item": "導入歌曲", "method_import_music_sheet": "導入歌單", "method_get_top_lists": "排行榜", "info_hint_you_have_no_plugin": "你尚未安裝任何插件", "info_hint_you_have_no_plugin_with_supported_method": "你尚未安裝支持 {{supportMethod}} 的插件", "info_hint_install_plugin_before_use": "請先前往 插件管理 安裝插件~" }, "download_page": { "waiting": "等待中...", "failed": "下載失敗" }, "plugin_management_page": { "plugin_management": "插件管理", "choose_plugin": "選擇插件", "install": "安裝", "musicfree_plugin": "MusicFree 插件", "install_successfully": "插件安裝成功", "install_failed": "安裝失敗", "invalid_plugin": "無效的插件", "install_from_local_file": "從本地文件安裝", "install_from_network": "從網絡安裝", "install_plugin_from_network": "從網絡安裝插件", "installing": "安裝中", "info_hint_install_placeholder": "輸入插件來源 URL(以 json 或 js 結尾)", "error_hint_plugin_should_end_with_js_or_json": "插件 URL 應以 json 或 js 結尾", "info_hint_install_plugin": "插件需符合 MusicFree 特定協議,詳情請參考 官方頁面", "subscription_setting": "訂閱設定", "update_subscription": "更新訂閱", "update_successfully": "更新成功", "no_subscription": "當前沒有訂閱", "uninstall": "卸載", "uninstall_plugin": "卸載插件", "confirm_text_uninstall_plugin": "確認卸載插件 {{plugin}}?", "uninstall_successfully": "插件 {{plugin}} 已卸載", "uninstall_failed": "卸載失敗", "toast_plugin_is_latest": "插件 {{plugin}} 已是最新版本", "update_failed": "更新失敗", "update": "更新", "importing_media": "導入中", "placeholder_import_music_item": "輸入 {{plugin}} 歌曲 URL", "import_failed": "導入失敗", "placeholder_import_music_sheet": "輸入 {{plugin}} 歌單 URL" }, "local_music_page": { "local_music": "本地音樂", "auto_scan": "自動掃描", "search_local_music": "搜尋本地音樂", "list_view": "列表視圖", "artist_view": "藝術家視圖", "album_view": "專輯視圖", "folder_view": "文件夾視圖", "total_music_num": "共 {{number}} 首歌曲" }, "music_list_context_menu": { "next_play": "下一首播放", "add_to_my_sheets": "添加到我的歌單", "remove_from_sheet": "從歌單移除", "delete_local_download": "刪除本地下載", "reveal_local_music_in_file_explorer": "打開歌曲文件夾", "reveal_local_music_in_file_explorer_fail": "打開失敗:", "delete_local_downloaded_songs_success": "已刪除 {{musicNums}} 首本地歌曲", "delete_local_downloaded_song_success": "已刪除本地歌曲 [{{songName}}]" }, "search_result_page": { "search_result_title": "搜尋結果" }, "side_bar": { "toplist": "排行榜", "recommend_sheets": "推薦歌單", "download_management": "下載管理", "local_music": "本地音樂", "plugin_management": "插件管理", "my_sheets": "我的歌單", "create_local_sheet": "創建歌單", "starred_sheets": "我的收藏", "delete_sheet": "刪除歌單", "rename_sheet": "重歌單", "unstar_sheet": "取消收藏", "recently_play": "最近播放" }, "app_header": { "nav_back": "返回", "nav_forward": "前進", "search_placeholder": "在這裡輸入搜尋", "search_history": "搜尋歷史", "settings": "設置", "minimize": "最小化", "minimode": "迷你模式", "exit": "退出", "theme": "主題" }, "music_bar": { "open_music_detail_page": "打開歌曲詳情", "close_music_detail_page": "關閉歌曲詳情", "previous_music": "上一首", "next_music": "下一首", "mute": "靜音", "unmute": "取消靜音", "playback_speed": "播放速度", "choose_music_quality": "選擇音樂音質", "only_set_for_current_music": "僅針對當前歌曲", "desktop_lyric": "桌面歌詞" }, "music_detail": { "search_lyric": "搜尋歌詞", "no_lyric": "暫無歌詞", "lyric_ctx_download_lyric": "下載歌詞", "lyric_ctx_download_lyric_lrc": "下載歌詞 (.lrc)", "lyric_ctx_download_lyric_txt": "下載歌詞 (.txt)", "lyric_ctx_download_success": "下載成功", "lyric_ctx_download_fail": "下載失敗", "lyric_ctx_set_font_size": "設置字體大小", "link_media_lyric": "鏈接歌詞", "media_lyric_linked": "已鏈接歌詞:", "unlink_media_lyric": "取消鏈接歌詞", "toast_media_lyric_unlinked": "已取消鏈接歌詞", "translation": "翻譯", "show_translation": "顯示翻譯", "hide_translation": "隱藏翻譯" }, "bottom_loading_state": { "reached_end": "~~~ 沒有更多了 ~~~", "loading": "加載中...", "load_more": "加載更多" }, "empty": { "hint_empty": "這裡什麼都沒有~~~" }, "modal": { "add_to_my_sheets": "添加到我的歌單", "total_music_num": "共 {{number}} 首歌曲", "create_local_sheet": "創建歌單", "create_local_sheet_placeholder": "請輸入新的歌單名稱", "exit_confirm": "確認退出?", "plugin_subscription": "插件訂閱", "subscription_remarks": "備註:", "subscription_links": "鏈接:", "subscription_save_success": "訂閱地址已保存", "search_lyric": "搜尋歌詞", "search_lyric_result_empty": "未找到相關結果", "media_lyric_linked": "歌詞已鏈接~", "media_lyric_link_failed": "鏈接歌詞失敗:", "new_version_found": "發現新版本", "latest_version": "最新版本:", "current_version": "當前版本:", "skip_this_version": "跳過此版本", "scan_local_music": "掃描本地音樂", "scan_local_music_hint": "將自動掃描選擇的文件夾(實時同步)", "add_folder": "添加文件夾" }, "panel": { "play_list_song_num": "播放清單({{number}} 首歌曲)", "user_variable": "用戶變量", "user_variable_setting_success": "設置成功~" }, "music_sheet_like_view": { "play_all": "播放全部", "add_to_sheet": "添加到歌單", "star": "收藏" }, "settings": { "choose_path": "選擇路徑", "change_path": "更改路徑", "folder_not_exist": "文件夾不存在", "open_folder": "打開文件夾", "section_name": { "normal": "一般", "play_music": "播放", "download": "下載", "lyric": "歌詞", "plugin": "插件", "theme": "主題", "short_cut": "快捷鍵", "network": "網絡", "backup": "備份與恢復", "about": "關於 MusicFree" }, "normal": { "check_update": "啟動應用時檢查更新", "auto_load_more": "(歌單頁) 滑動到頁面底部時自動加載更多", "close_behavior": "點擊退出按鈕時", "exit_app": "退出應用", "minimize": "最小化到系統托盤", "taskbar_thumb": "任務欄縮略圖樣式(重啟應用後生效)", "current_artwork": "當前歌曲封面", "main_window": "主窗口界面", "max_history_length": "搜尋歷史記錄最大數量", "music_list_hide_columns": "歌曲列表隱藏列", "languages": "語言", "toast_switch_language_fail": "切換語言失敗" }, "play_music": { "case_sensitive_in_search": "搜尋歌單時區分大小寫", "default_play_quality": "預設播放音質", "when_quality_missing": "當播放音質缺失時", "play_lower_quality_version": "播放低音質版本", "play_higher_quality_version": "播放高音質版本", "play_skip_quality_version": "不尋找其他音質版本", "when_play_error": "播放錯誤時", "pause": "暫停", "skip_to_next": "跳至下一首", "double_click_music_list": "雙擊歌曲列表時", "add_music_to_playlist": "添加歌曲到播放清單", "replace_playlist_with_musiclist": "用當前歌單替換播放清單", "audio_output_device": "音頻輸出設備", "when_device_removed": "當音頻裝置被移除時", "continue_playing": "繼續播放" }, "download": { "download_folder": "下載文件夾", "max_concurrency": "最大同時下載數量", "default_download_quality": "預設下載音質", "when_quality_missing": "當下載音質缺失時", "download_lower_quality_version": "下載低音質版本", "download_higher_quality_version": "下載高音質版本" }, "lyric": { "enable_status_bar_lyric": "啟用狀態欄歌詞", "enable_desktop_lyric": "啟用桌面歌詞", "lock_desktop_lyric": "鎖定桌面歌詞", "font": "字體", "font_size": "字體大小", "font_color": "字體顏色", "stroke_color": "描邊顏色" }, "plugin": { "auto_update_plugin": "啟動應用時自動更新插件", "not_check_plugin_version": "安裝插件時不檢查版本" }, "short_cut": { "enable_local": "啟用應用內快捷鍵", "enable_global": "啟用全局快捷鍵", "ability": "功能", "local_short_cut": "應用內快捷鍵", "global_short_cut": "全局快捷鍵", "play/pause": "播放/暫停", "skip-next": "下一首", "skip-previous": "上一首", "volume-up": "音量增加", "volume-down": "音量減少", "toggle-desktop-lyric": "開啟/關閉桌面歌詞", "like/dislike": "喜歡/不喜歡當前歌曲", "no_short_cut": "無", "toggle-main-window-visible": "顯示/隱藏主窗口" }, "network": { "host": "主機", "port": "端口", "username": "用戶名", "password": "密碼", "local_cache": "本地緩存:{{cacheSize}}", "clear_cache": "清除緩存", "enable_network_proxy": "啟用網絡代理" }, "backup": { "resume_mode_append": "附加到現有清單後", "resume_mode_overwrite": "覆蓋現有清單", "backup_by_file": "通過文件備份", "musicfree_backup_file": "MusicFree 備份文件", "backup_to": "備份到...", "backup_by_webdav": "WebDAV 備份", "backup_success": "備份成功~", "backup_fail": "備份失敗:{{reason}}", "resume_success": "恢復成功~", "resume_fail": "恢復失敗:{{reason}}", "backup_music_sheet": "備份歌單", "resume_music_sheet": "恢復歌單", "webdav_server_url": "URL", "username": "用戶名", "password": "密碼", "webdav_data_not_complete": "URL、用戶名和密碼不能為空", "webdav_backup_file_not_exist": "備份文件不存在" }, "about": { "current_version": "當前版本:{{version}}", "already_latest": "已是最新版本!", "check_update": "檢查更新", "software_author": "軟件作者:", "open_source_declaration": "源碼:軟件是基於 AGPL3.0 授權的開源軟件。Github 地址 Gitee 地址", "official_site": "官方網站", "mobile_version": "移動版" } }, "main": { "previous_music": "上一首歌曲", "next_music": "下一首歌曲", "close_desktop_lyric": "關閉桌面歌詞", "open_desktop_lyric": "開啟桌面歌詞", "unlock_desktop_lyric": "解鎖桌面歌詞", "lock_desktop_lyric": "鎖定桌面歌詞", "no_playing_music": "目前無正在播放的音樂" }, "theme": { "tab_local": "本地主題", "tab_remote": "主題市場", "download_and_use": "下載並使用", "use_theme": "使用主題", "install_theme": "安裝主題", "update_theme": "更新主題", "uninstall_theme": "卸載主題", "musicfree_theme": "MusicFree 主題", "all_files": "全部檔案", "install_theme_success": "安裝主題{{name}}成功~", "install_theme_fail": "安裝主題失敗: {{reason}}", "uninstall_theme_success": "卸載主題{{name}}成功~", "uninstall_theme_fail": "卸載主題失敗: {{reason}}", "how_to_submit_new_theme": "💡如何提交新主題: 主題市場中的主題與 MusicFreeThemePacks 倉庫同步,如果需要提交新主題請直接提PR。", "load_remote_theme_error": "出錯啦...", "invalid_theme": "主題無效: {{reason}}" } } ================================================ FILE: scripts/feishu-upload.js ================================================ const fs = require('fs'); const path = require('path'); const { Readable } = require('stream'); const { Client } = require('@larksuiteoapi/node-sdk'); /** * 飞书云空间文件上传工具 * 支持小文件直接上传和大文件分片上传 * 包含重试机制和错误处理 */ class FeishuFileUploader { constructor(appId, appSecret, tenantAccessToken = null) { this.client = new Client({ appId: appId, appSecret: appSecret, disableTokenCache: true }); this.appId = appId; this.appSecret = appSecret; this.tenantAccessToken = tenantAccessToken; this.tokenExpireTime = null; this.maxRetries = 5; // 最大重试次数 this.retryDelay = 1000; // 重试延迟(毫秒) this.maxFileSize = 20 * 1024 * 1024; // 20MB - 小文件直接上传的阈值 this.chunkSize = 4 * 1024 * 1024; // 4MB - 分片大小 } /** * 获取tenant_access_token */ async getTenantAccessToken() { // 如果token存在且未过期(剩余时间超过5分钟),直接返回 if (this.tenantAccessToken && this.tokenExpireTime) { const now = Date.now(); const remainingTime = this.tokenExpireTime - now; if (remainingTime > 5 * 60 * 1000) { // 5分钟 return this.tenantAccessToken; } } console.log('正在获取 tenant_access_token...'); try { const response = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }) }); const data = await response.json(); if (data.code !== 0) { throw new Error(`获取 tenant_access_token 失败: ${data.msg}`); } this.tenantAccessToken = data.tenant_access_token; this.tokenExpireTime = Date.now() + (data.expire * 1000); console.log('tenant_access_token 获取成功'); return this.tenantAccessToken; } catch (error) { console.error('获取 tenant_access_token 失败:', error.message); throw error; } } /** * 计算Adler-32校验和 */ calculateAdler32(buffer) { const MOD = 65521; let s1 = 1; // 初始低位累加和 let s2 = 0; // 初始高位累加和 // 遍历缓冲区中的每个字节 for (let i = 0; i < buffer.length; i++) { // 获取无符号字节值 (0-255) const byte = buffer[i]; // 更新累加值,使用模数防止溢出 s1 = (s1 + byte) % MOD; s2 = (s2 + s1) % MOD; } // 组合结果: (s2 << 16) | s1 const combinedValue = (s2 << 16) | s1; // 确保结果是32位无符号整数 // 注意:JavaScript 的位操作符返回的是32位有符号整数,所以需要转换 const result = combinedValue >>> 0; // 返回十进制格式的字符串 return result.toString(10); } /** * 延迟函数 */ delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 重试包装器 */ async withRetry(operation, operationName) { let lastError; for (let attempt = 1; attempt <= this.maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error; // 检查是否是可重试的错误 const isRetryableError = this.isRetryableError(error); if (!isRetryableError || attempt === this.maxRetries) { console.error(`${operationName} 失败 (尝试 ${attempt}/${this.maxRetries}):`, error.message); throw error; } const delayTime = this.retryDelay * Math.pow(2, attempt - 1); // 指数退避 console.log(`${operationName} 失败 (尝试 ${attempt}/${this.maxRetries}), ${delayTime}ms 后重试...`); await this.delay(delayTime); } } throw lastError; } /** * 判断是否为可重试的错误 */ isRetryableError(error) { if (error.response && error.response.data) { const code = error.response.data.code; // 1061045: 频率限制错误,可重试 // 1061001: 内部错误,可重试 // 1064230: 数据迁移中,可重试 return [1061045, 1061001, 1064230].includes(code); } // 网络错误等也可以重试 return error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND'; } /** * 直接上传小文件 */ async uploadSmallFile(filePath, fileName, parentNode) { const fileBuffer = fs.readFileSync(filePath); const token = await this.getTenantAccessToken(); return await this.withRetry(async () => { const response = await this.client.drive.v1.file.upload({ data: { file_name: fileName, parent_type: 'explorer', parent_node: parentNode, size: fileBuffer.length, file: fileBuffer } }, { headers: { 'Authorization': `Bearer ${token}` } }); return response.data; }, '小文件上传'); } /** * 分片上传预处理 */ async uploadPrepare(fileName, parentNode, fileSize) { const token = await this.getTenantAccessToken(); return await this.withRetry(async () => { const response = await this.client.drive.v1.file.uploadPrepare({ data: { file_name: fileName, parent_type: 'explorer', parent_node: parentNode, size: fileSize } }, { headers: { 'Authorization': `Bearer ${token}` } }); return response.data; }, '预上传'); }/** * 上传单个分片 */ async uploadPart(uploadId, seq, chunkBuffer) { const checksum = this.calculateAdler32(chunkBuffer); const token = await this.getTenantAccessToken(); // 创建一个真正的可读流 const chunkStream = new Readable({ read() { } }); chunkStream.push(chunkBuffer); chunkStream.push(null); // 标记流结束 return await this.withRetry(async () => { const response = await this.client.drive.v1.file.uploadPart({ data: { upload_id: uploadId, seq: seq, size: chunkBuffer.length, checksum: checksum, file: chunkStream } }, { headers: { 'Authorization': `Bearer ${token}` } }); return response?.data; }, `分片上传 (${seq})`); } /** * 完成分片上传 */ async uploadFinish(uploadId, blockNum) { const token = await this.getTenantAccessToken(); return await this.withRetry(async () => { const response = await this.client.drive.v1.file.uploadFinish({ data: { upload_id: uploadId, block_num: blockNum } }, { headers: { 'Authorization': `Bearer ${token}` } }); return response.data; }, '完成上传'); } /** * 分片上传大文件 */ async uploadLargeFile(filePath, fileName, parentNode) { const fileStats = fs.statSync(filePath); const fileSize = fileStats.size; console.log(`开始分片上传文件: ${fileName} (${fileSize} bytes)`); // 1. 预上传 const prepareResult = await this.uploadPrepare(fileName, parentNode, fileSize); const { upload_id, block_size, block_num } = prepareResult; console.log(`预上传成功, upload_id: ${upload_id}, 分片数量: ${block_num}`); // 2. 分片上传 const fileHandle = fs.openSync(filePath, 'r'); try { for (let i = 0; i < block_num; i++) { const start = i * block_size; const end = Math.min(start + block_size, fileSize); const chunkSize = end - start; const chunkBuffer = Buffer.alloc(chunkSize); fs.readSync(fileHandle, chunkBuffer, 0, chunkSize, start); console.log(`上传分片 ${i + 1}/${block_num} (${chunkSize} bytes)`); await this.uploadPart(upload_id, i, chunkBuffer); // 避免频率限制,分片之间稍微延迟 if (i < block_num - 1) { await this.delay(200); } } } finally { fs.closeSync(fileHandle); } // 3. 完成上传 console.log('完成分片上传...'); const finishResult = await this.uploadFinish(upload_id, block_num); return finishResult; } /** * 主上传函数 - 自动选择上传方式 */ async uploadFile(filePath, fileName, parentNode) { if (!fs.existsSync(filePath)) { throw new Error(`文件不存在: ${filePath}`); } const fileStats = fs.statSync(filePath); const fileSize = fileStats.size; console.log(`准备上传文件: ${fileName}`); console.log(`文件大小: ${fileSize} bytes`); console.log(`目标文件夹: ${parentNode}`); try { let result; if (fileSize <= this.maxFileSize) { console.log('使用直接上传方式'); result = await this.uploadSmallFile(filePath, fileName, parentNode); } else { console.log('使用分片上传方式'); result = await this.uploadLargeFile(filePath, fileName, parentNode); } console.log('文件上传成功!'); return result; } catch (error) { console.error('文件上传失败:', error.message); if (error.response && error.response.data) { console.error('错误详情:', error.response.data); } throw error; } } } /** * 主函数 - 从环境变量和命令行参数获取配置 */ async function main() { try { // 从环境变量获取配置 const appId = process.env.FEISHU_APP_ID; const appSecret = process.env.FEISHU_APP_SECRET; const parentNode = process.env.FEISHU_PARENT_NODE; // 从命令行参数获取文件路径和文件名 const args = process.argv.slice(2); if (args.length < 1) { console.error('用法: node feishu-upload.js <文件路径> [上传文件名]'); console.error(''); console.error('环境变量:'); console.error(' FEISHU_APP_ID - 飞书应用ID'); console.error(' FEISHU_APP_SECRET - 飞书应用密钥'); console.error(' FEISHU_PARENT_NODE - 云空间文件夹token'); process.exit(1); } const filePath = path.resolve(args[0]); const fileName = args[1] || path.basename(filePath); // 验证环境变量 if (!appId || !appSecret || !parentNode) { console.error('错误: 缺少必要的环境变量'); console.error('请设置: FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_PARENT_NODE'); process.exit(1); } // 创建上传器实例 const uploader = new FeishuFileUploader(appId, appSecret); // 执行上传 const result = await uploader.uploadFile(filePath, fileName, parentNode); console.log('上传结果:', result); if (result.file_token) { console.log(`文件上传成功! file_token: ${result.file_token}`); } } catch (error) { console.error('上传失败:', error.message); process.exit(1); } } // 如果直接运行此脚本,则执行主函数 if (require.main === module) { main(); } // 导出类以供其他模块使用 module.exports = { FeishuFileUploader }; ================================================ FILE: src/common/async-memoize.ts ================================================ export default function asyncMemoize Promise>(callback: T): T{ let val: R; return (async (...args: any[]) => { if(!val) { val = await callback(...args); } return val; }) as T; } ================================================ FILE: src/common/camel-to-snake.ts ================================================ export default function camelToSnake(camelCaseStr: string): string { return camelCaseStr.replace(/([A-Z])/g, "_$1").toLowerCase(); // 将整个字符串转换为小写 } ================================================ FILE: src/common/constant.ts ================================================ import { IAppConfig } from "@/types/app-config"; import { ICommand } from "@shared/message-bus/type"; export const internalDataKey = "$"; export const internalDataSymbol = Symbol.for("internal"); // 加入播放列表/歌单的时间 export const timeStampSymbol = Symbol.for("time-stamp"); // 加入播放列表的辅助顺序 export const sortIndexSymbol = Symbol.for("sort-index"); /** * 歌曲引用次数 * TODO: 没必要算引用 如果真有需要直接取异或就可以了 */ export const musicRefSymbol = "$$ref"; /** 本地存储路径 */ export const localFilePathSymbol = Symbol.for("local-file-path"); export const localPluginName = "本地"; export const localPluginHash = "本地"; export const supportedMediaType = [ "music", "album", "artist", "sheet", ] as const; export const rem = 13; export enum RequestStateCode { /** 空闲 */ IDLE = 0b00000000, PENDING_FIRST_PAGE = 0b00000010, // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values LOADING = 0b00000010, /** 检索中 */ PENDING_REST_PAGE = 0b00000011, /** 部分结束 */ PARTLY_DONE = 0b00000100, /** 全部结束 */ FINISHED = 0b0001000, /** 出错了 */ ERROR = 0b10000000, } /** 音质列表 */ export const qualityKeys: IMusic.IQualityKey[] = [ "low", "standard", "high", "super", ]; export const supportLocalMediaType = [ ".mp3", ".mp4", ".m4s", ".flac", ".wma", ".wav", ".m4a", ".ogg", ".acc", ".aac", // ".ape", ".opus", ]; export const toastDuration = { short: 1000, long: 2500, }; export const defaultFont = { fullName: "默认", family: "", postscriptName: "", style: "", }; type IShortCutKeys = keyof IAppConfig["shortCut.shortcuts"]; export const shortCutKeys: IShortCutKeys[] = [ "play/pause", "skip-next", "skip-previous", "volume-up", "volume-down", "toggle-desktop-lyric", "like/dislike", "toggle-main-window-visible", ]; // 快捷键列表对应的指令 export const shortCutKeysCommands: Record = { "play/pause": "TogglePlayerState", "skip-next": "SkipToNext", "skip-previous": "SkipToPrevious", "volume-down": "VolumeDown", "volume-up": "VolumeUp", "toggle-desktop-lyric": "ToggleDesktopLyric", "like/dislike": "ToggleFavorite", "toggle-main-window-visible": "ToggleMainWindowVisible", }; // 主进程的Resource export enum ResourceName { SKIP_LEFT_ICON = "skip-left.png", SKIP_RIGHT_ICON = "skip-right.png", PAUSE_ICON = "pause.png", PLAY_ICON = "play.png", DEFAULT_ALBUM_COVER_IMAGE = "album-cover.jpeg", LOGO_IMAGE = "logo.png", } /** 下载状态 */ export enum DownloadState { /** 空闲状态 */ NONE = "NONE", /** 排队等待中 */ WAITING = "WAITING", /** 下载中 */ DOWNLOADING = "DOWNLOADING", /** 失败 */ ERROR = "ERROR", /** 下载完成 */ DONE = "DONE", } // 主题更新链接 export const themePackStoreBaseUrl = [ "https://raw.githubusercontent.com/maotoumao/MusicFreeThemePacks/master/", //github "https://cdn.jsdelivr.net/gh/maotoumao/MusicFreeThemePacks@master/", "https://dev.azure.com/maotoumao/MusicFree/_apis/git/repositories/MusicFreeThemePacks/items?scopePath=/.publish/publish.json&api-version=6.0", // azure ]; export const appUpdateSources = [ "https://gitee.com/maotoumao/MusicFreeDesktop/raw/master/release/version.json", "https://raw.githubusercontent.com/maotoumao/MusicFreeDesktop/master/release/version.json", "https://cdn.jsdelivr.net/gh/maotoumao/MusicFreeDesktop@master/release/version.json", ]; export enum TrackPlayerSyncType { SyncPlayerState = "SyncPlayerState", MusicChanged = "MusicChanged", PlayerStateChanged = "PlayerStateChanged", RepeatModeChanged = "RepeatModeChanged", LyricChanged = "LyricChanged", CurrentLyricChanged = "CurrentLyricChanged", ProgressChanged = "ProgressChanged", } /** 播放器状态 */ export enum PlayerState { /** 无音频 */ None, /** 播放中 */ Playing, /** 暂停 */ Paused, /** 缓冲中 */ Buffering, } /** 播放模式 */ export enum RepeatMode { /** 随机 */ Shuffle = "shuffle", /** 播放队列 */ Queue = "queue-repeat", /** 单曲循环 */ Loop = "loop", } /** 窗口类型 */ export enum WindowType { MAIN = "MAIN", LYRIC = "LYRIC", MINIMODE = "MINIMODE", } export enum WindowRole { MAIN = "MAIN", SLAVE = "SLAVE", } export const CommonConst = { /** 新建歌单名称长度限制 */ NEW_SHEET_NAME_LENGTH_LIMIT: 120, }; ================================================ FILE: src/common/debounce.ts ================================================ import debounce from "lodash.debounce"; export default function ( ...args: Parameters ): ReturnType { const [ func, wait = 500, options = { leading: true, trailing: false, }, ] = args; return debounce(func, wait, options); } ================================================ FILE: src/common/event-wrapper.ts ================================================ import EventEmitter from "eventemitter3"; import { useEffect } from "react"; class EventWrapper { private ee: EventEmitter; constructor() { this.ee = new EventEmitter(); } /** * 监听 * @param eventName 事件名 * @param callBack 回调 */ on( eventName: K, callBack: (payload: T[K]) => void, ) { this.ee.on(eventName, callBack); } once( eventName: K, callBack: (payload: T[K]) => void, ) { this.ee.once(eventName, callBack); } emit( eventName: K, payload?: T[K], ) { this.ee.emit(eventName, payload); } off( eventName: K, callBack: (payload: T[K]) => void, ) { this.ee.off(eventName, callBack); } use( eventName: K, callBack: (payload: T[K]) => void, ) { useEffect(() => { this.ee.on(eventName, callBack); return () => { this.ee.off(eventName, callBack); }; }, []); } } export default EventWrapper; ================================================ FILE: src/common/file-util.ts ================================================ import { ICommonTagsResult, IPicture, parseFile } from "music-metadata"; import path from "path"; import { localPluginName, supportLocalMediaType } from "./constant"; import CryptoJS from "crypto-js"; import fs from "fs/promises"; import url from "url"; import type { BigIntStats, PathLike, StatOptions, Stats } from "original-fs"; function getB64Picture(picture: IPicture) { return `data:${picture.format};base64,${picture.data.toString("base64")}`; } const specialEncoding = ["GB2312"]; export async function parseLocalMusicItem( filePath: string, ): Promise { const hash = CryptoJS.MD5(filePath).toString(); try { const { common = {} as ICommonTagsResult } = await parseFile(filePath); const jschardet = await import("jschardet"); // 检测编码 let encoding: string | null = null; let conf = 0; const testItems = [common.title, common.artist, common.album]; for (const testItem of testItems) { if (!testItem) { continue; } const testResult = jschardet.detect(testItem, { minimumThreshold: 0.4, }); if (testResult.confidence > conf) { conf = testResult.confidence; encoding = testResult.encoding; } if (conf > 0.9) { break; } } if (specialEncoding.includes(encoding)) { const iconv = await import("iconv-lite"); if (common.title) { common.title = iconv.decode( common.title as unknown as Buffer, encoding, ); } if (common.artist) { common.artist = iconv.decode( common.artist as unknown as Buffer, encoding, ); } if (common.artist) { common.album = iconv.decode( common.album as unknown as Buffer, encoding, ); } if (common.lyrics) { common.lyrics = common.lyrics.map((it) => it ? iconv.decode(it as unknown as Buffer, encoding) : "", ); } } return { title: common.title ?? path.parse(filePath).name, artist: common.artist ?? "未知作者", artwork: common.picture?.[0] ? getB64Picture(common.picture[0]) : undefined, album: common.album ?? "未知专辑", url: addFileScheme(filePath), localPath: filePath, platform: localPluginName, id: hash, rawLrc: common.lyrics?.join(""), }; } catch (e) { return { title: path.parse(filePath).name || filePath, id: hash, platform: localPluginName, localPath: filePath, url: addFileScheme(filePath), artist: "未知作者", album: "未知专辑", }; } } export async function parseLocalMusicItemFolder( folderPath: string, ): Promise { /** * 1. 筛选出符合条件的 */ try { const folderStat = await fs.stat(folderPath); if (folderStat.isDirectory()) { const files = await fs.readdir(folderPath); const validFiles = files.filter((fp) => supportLocalMediaType.some((postfix) => fp.endsWith(postfix)), ); // TODO: 分片 return Promise.all( validFiles.map((fp) => parseLocalMusicItem(path.resolve(folderPath, fp)), ), ); } throw new Error("Folder Not Found"); } catch { return []; } } export function addFileScheme(filePath: string) { return filePath.startsWith("file:") ? filePath : url.pathToFileURL(filePath).toString(); } export function addTailSlash(filePath: string) { return filePath.endsWith("/") || filePath.endsWith("\\") ? filePath : filePath + "/"; } export async function safeStat( path: PathLike, opts?: StatOptions, ): Promise { try { return await fs.stat(path, opts); } catch { return null; } } ================================================ FILE: src/common/get-resource-path.ts ================================================ /** * 只在主进程中使用 获取资源文件的绝对路径 * @param resourceName 资源文件名 * @return 资源文件的绝对路径 */ import { app } from "electron"; import path from "path"; const resPath = app.isPackaged ? path.resolve(process.resourcesPath, "res") : path.resolve(__dirname, "../../res"); export default (resourceName: string) => { return path.resolve(resPath, resourceName); }; ================================================ FILE: src/common/index-map.ts ================================================ export interface IIndexMap { indexOf: (mediaItem?: IMedia.IMediaBase | null) => number; has: (mediaItem?: IMedia.IMediaBase | null) => boolean; update: (mediaItems?: IMedia.IMediaBase[]) => void; } export function createIndexMap(mediaItems?: IMedia.IMediaBase[]): IIndexMap { const indexMap: Map> = new Map(); update(mediaItems); function update(mediaItems?: IMedia.IMediaBase[]) { indexMap.clear(); if (!mediaItems) { return; } mediaItems?.forEach((mediaItem, index) => { if (!mediaItem) { return; } const { platform, id } = mediaItem; let idMap = indexMap.get(platform); if (!idMap) { idMap = new Map(); indexMap.set(platform, idMap); } idMap.set(id, index); }); } function indexOf(mediaItem?: IMedia.IMediaBase | null) { if (!mediaItem) { return -1; } return indexMap.get(mediaItem?.platform)?.get(mediaItem?.id) ?? -1; } function has(mediaItem?: IMedia.IMediaBase | null) { if (!mediaItem) { return false; } return indexMap.get(mediaItem?.platform)?.has(mediaItem?.id) ?? false; } return { update, indexOf, has, }; } ================================================ FILE: src/common/is-renderer.ts ================================================ export function isRenderer() { if (typeof process === "undefined" || !process) { // renderer process has no process variable with nodeIntegration off and sandbox on return true; } return process.type === "renderer"; } ================================================ FILE: src/common/media-util.ts ================================================ import { produce, setAutoFreeze } from "immer"; import { internalDataKey, localPluginName, qualityKeys, sortIndexSymbol, timeStampSymbol, } from "./constant"; setAutoFreeze(false); export function isSameMedia( a?: IMedia.IMediaBase | null, b?: IMedia.IMediaBase | null, ) { if (a && b) { return a.id === b.id && a.platform === b.platform; } return false; } export function resetMediaItem( mediaItem: T, platform?: string, newObj?: boolean, ): T { // 本地音乐不做处理 if (mediaItem.platform === localPluginName || platform === localPluginName) { return newObj ? { ...mediaItem } : mediaItem; } if (!newObj) { mediaItem.platform = platform ?? mediaItem.platform; mediaItem[internalDataKey] = undefined; return mediaItem; } else { return produce(mediaItem, (_) => { _.platform = platform ?? mediaItem.platform; _[internalDataKey] = undefined; }); } } export function getMediaPrimaryKey(mediaItem: IMedia.IUnique) { if (mediaItem) { return `${mediaItem.platform}@${mediaItem.id}`; } return "invalid@invalid"; } export function sortByTimestampAndIndex(array: any[], newArray = false) { if (newArray) { array = [...array]; } return array.sort((a, b) => { const ts = a[timeStampSymbol] - b[timeStampSymbol]; if (ts !== 0) { return ts; } return a[sortIndexSymbol] - b[sortIndexSymbol]; }); } export function addSortProperty( mediaItems: IMedia.IMediaBase | IMedia.IMediaBase[], ) { const now = Date.now(); if (Array.isArray(mediaItems)) { mediaItems.forEach((item, index) => { if (!item) { return; } item[timeStampSymbol] = now; item[sortIndexSymbol] = index; }); } else { if (!mediaItems) { return; } mediaItems[timeStampSymbol] = now; mediaItems[sortIndexSymbol] = 0; } } export function flatMediaItem(mediaItem: T) { if (!mediaItem) { return mediaItem; } return { ...mediaItem, ...(mediaItem?.$raw || {}), platform: mediaItem.platform || mediaItem?.$raw?.platform, id: mediaItem.id || mediaItem?.$raw?.id, } as T; } export function removeInternalProperties( mediaItem: T, ) { if (!mediaItem) { return mediaItem; } const keys = Object.keys(mediaItem); return keys.reduce((obj, key) => { if (!key.startsWith("$")) { obj[key] = mediaItem[key]; } return obj; }, {} as any) as T; } /** * 获取音质顺序 * * higher: 优先高音质 * lower:优先低音质 */ export function getQualityOrder( qualityKey: IMusic.IQualityKey, sort: "higher" | "lower" | "skip", ) { if (sort === "skip") { return [qualityKey]; } const idx = qualityKeys.indexOf(qualityKey); const left = qualityKeys.slice(0, idx); const right = qualityKeys.slice(idx + 1); if (sort === "higher") { /** 优先高音质 */ return [qualityKey, ...right, ...left.reverse()]; } else { /** 优先低音质 */ return [qualityKey, ...left.reverse(), ...right]; } } /** 获取内部属性 */ export function getInternalData< T extends Record, K extends keyof T = keyof T, >(mediaItem: IMedia.IMediaBase, internalProp: K): T[K] | null { if (!mediaItem || !mediaItem[internalDataKey]) { return null; } return mediaItem[internalDataKey][internalProp] ?? null; } export function setInternalData< T extends Record, K extends keyof T = keyof T, R extends IMedia.IMediaBase = IMedia.IMediaBase, >(mediaItem: R, internalProp: K, value: T[K] | null, newObj = false): R { if (newObj) { return { ...mediaItem, [internalDataKey]: { ...(mediaItem[internalDataKey] ?? {}), [internalProp]: value, }, }; } mediaItem[internalDataKey] = mediaItem[internalDataKey] ?? {}; mediaItem[internalDataKey][internalProp] = value; return mediaItem; } export function toMediaBase(media: IMedia.IMediaBase) { return { platform: media.platform, id: media.id, }; } ================================================ FILE: src/common/normalize-util.ts ================================================ export function normalizeNumberCN(number: number): string { if (number < 10000) { return `${number}`; } number = number / 10000; if (number < 10000) { return `${number.toFixed(number < 1000 ? 1 : 0)}万`; } number = number / 10000; return `${number.toFixed(number < 1000 ? 1 : 0)}亿`; } export function normalizeNumberEN(number: number): string { if (number < 10000) { return `${number}`; } number = number / 1000; if (number < 1000) { return `${number.toFixed(number < 1000 ? 1 : 0)} K`; } number = number / 1000; if (number < 1000) { return `${number.toFixed(number < 1000 ? 1 : 0)} M`; } number = number / 100; return `${number.toFixed(number < 1000 ? 1 : 0)} B`; } export function normalizeNumber(number: number, en?: boolean): string { const _n = +number; if (isNaN(_n) || !isFinite(_n)) { return "-"; } else if (isFinite(_n)) { return en ? normalizeNumberEN(_n) : normalizeNumberCN(_n); } } export function addRandomHash(url: string) { if (url.indexOf("#") === -1) { return `${url}#${Date.now()}`; } return url; } /** url hack */ export function encodeUrlHeaders( originalUrl: string, headers?: Record, ) { let formalizedKey: string; const _setHeaders: Record = {}; for (const key in headers) { formalizedKey = key.toLowerCase(); _setHeaders[formalizedKey] = headers[key]; } const encodedUrl = new URL(originalUrl); encodedUrl.searchParams.set( "_setHeaders", encodeURIComponent(JSON.stringify(_setHeaders)), ); return encodedUrl.toString(); } export function isBetween(target: number, a: number, b: number) { if (a > b) { return a >= target && target >= b; } return b >= target && target >= a; } export function isBasicType(val: unknown) { const tp = typeof val; if ( tp === "string" || tp === "boolean" || tp === "number" || tp === "undefined" || val === null ) { return true; } return false; } const fileSizeUnits = ["B", "KB", "MB", "GB", "TB"]; export function normalizeFileSize(bytes: number) { let ptr = 0; while (bytes >= 1024 && ptr < fileSizeUnits.length) { bytes = bytes / 1024; ++ptr; } return `${bytes.toFixed(1)}${fileSizeUnits[ptr]}`; } ================================================ FILE: src/common/safe-serialization.ts ================================================ export function safeStringify(object: object) { try { return JSON.stringify(object); } catch { return ""; } } export function safeParse(str: string) { try { return JSON.parse(str); } catch { return null; } } ================================================ FILE: src/common/store.ts ================================================ // 数据存储方案 import { useEffect, useState } from "react"; export class StateMapper { private getFun: () => T; public cbs: Set<() => void> = new Set([]); constructor(getFun: () => T) { this.getFun = getFun; } notify = () => { this.cbs.forEach((_) => _?.()); }; useMappedState = () => { const [_state, _setState] = useState(this.getFun); const updateState = () => { _setState(this.getFun()); }; useEffect(() => { this.cbs.add(updateState); return () => { this.cbs.delete(updateState); }; }, []); return _state; }; } type UpdateFunc = (prev: T) => T; export default class Store { private value: T; private stateMapper: StateMapper; private valueChangeCbs: Set<(newValue: T, oldValue: T) => void> = new Set([]); constructor(initValue: T) { this.value = initValue; this.stateMapper = new StateMapper(this.getValue); } public getValue = () => { return this.value; }; public useValue = () => { return this.stateMapper.useMappedState(); }; public setValue = (value: T | UpdateFunc) => { let newValue: T; if (typeof value === "function") { newValue = (value as UpdateFunc)(this.value); } else { newValue = value; } this.valueChangeCbs.forEach((cb) => { cb(newValue, this.value); }); this.value = newValue; this.stateMapper.notify(); }; public onValueChange = (cb: (newValue: T, oldValue: T) => void) => { this.valueChangeCbs.add(cb); return () => { this.valueChangeCbs.delete(cb); }; }; } export function useStore(store: Store) { return [store.useValue(), store.setValue] as const; } ================================================ FILE: src/common/thumb-bar-util.ts ================================================ /** * Thumb Bar Util */ import { BrowserWindow, nativeImage } from "electron"; import getResourcePath from "@/common/get-resource-path"; import { t } from "@shared/i18n/main"; import { ResourceName } from "@/common/constant"; import asyncMemoize from "@/common/async-memoize"; import fs from "fs/promises"; import logger from "@shared/logger/main"; import axios from "axios"; import messageBus from "@shared/message-bus/main"; /** * 设置缩略图按钮 * @param window 当前窗口 * @param isPlaying 当前是否正在播放音乐 */ function setThumbBarButtons(window: BrowserWindow, isPlaying?: boolean) { if (!window) { return; } window.setThumbarButtons([ { icon: nativeImage.createFromPath(getResourcePath(ResourceName.SKIP_LEFT_ICON)), tooltip: t("main.previous_music"), click() { messageBus.sendCommand("SkipToPrevious"); }, }, { icon: nativeImage.createFromPath( getResourcePath(isPlaying ? ResourceName.PAUSE_ICON : ResourceName.PLAY_ICON), ), tooltip: isPlaying ? t("media.music_state_pause") : t("media.music_state_play"), click() { messageBus.sendCommand( "TogglePlayerState", ); }, }, { icon: nativeImage.createFromPath(getResourcePath(ResourceName.SKIP_RIGHT_ICON)), tooltip: t("main.next_music"), click() { messageBus.sendCommand("SkipToNext"); }, }, ]); } // 获取默认的图片 const getDefaultAlbumCoverImage = asyncMemoize(async () => { return await fs.readFile((getResourcePath(ResourceName.DEFAULT_ALBUM_COVER_IMAGE))); }); let hookedFlag = false; /** * 设置缩略图 * @param window 窗口 * @param src 图片url */ async function setThumbImage(window: BrowserWindow, src: string) { if (!window) { return; } // only support windows if (process.platform !== "win32") { return; } try { const hwnd = window.getNativeWindowHandle().readBigUInt64LE(0); const taskBarThumbManager = (await import("@native/TaskbarThumbnailManager/TaskbarThumbnailManager.node")).default; if (!hookedFlag) { taskBarThumbManager.config(hwnd); hookedFlag = true; } let buffer: Buffer; if (!src) { buffer = await getDefaultAlbumCoverImage(); } else if (src.startsWith("http")) { try { buffer = ( await axios.get(src, { responseType: "arraybuffer", }) ).data; } catch { buffer = await getDefaultAlbumCoverImage(); } } else if (src.startsWith("data:image")) { buffer = Buffer.from(src.split(";base64,").pop(), "base64"); } else { buffer = await getDefaultAlbumCoverImage(); } const size = 106; const sharp = (await import("sharp")).default; const result = await sharp(buffer) .resize(size, size, { fit: "cover", }) .png() .ensureAlpha(1) .raw() .toBuffer({ resolveWithObject: true, }); taskBarThumbManager.sendIconicRepresentation( hwnd, { width: size, height: size, }, result.data, ); } catch (ex) { logger.logError("Fail to setThumbImage", ex); } } const ThumbBarManager = { setThumbBarButtons, setThumbImage, }; export default ThumbBarManager; ================================================ FILE: src/common/time-util.ts ================================================ export function secondsToDuration(seconds: number | string) { if (typeof seconds === "string") { return seconds; } else { const sec = seconds % 60; seconds = Math.floor(seconds / 60); const min = seconds % 60; const hour = Math.floor(seconds / 60); const ms = `${min}`.padStart(2, "0") + ":" + `${Math.floor(sec)}`.padStart(2, "0"); if (hour === 0) { return ms; } else { return `${hour}:${ms}`; } } } export function delay(millsecond: number) { return new Promise((resolve) => { setTimeout(() => { resolve(); }, millsecond); }); } ================================================ FILE: src/common/unique-map.ts ================================================ export interface IUniqueMap { getMap: () => Record>; has: (mediaItem?: IMedia.IMediaBase | null) => boolean; add: (mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) => void; remove: (mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) => void; } export function createUniqueMap(mediaItems?: IMedia.IMediaBase[]): IUniqueMap { const uniqueMap: Record> = {}; mediaItems?.forEach((item) => { add(item); }); function getMap() { return uniqueMap; } function has(mediaItem?: IMedia.IMediaBase | null) { if (!mediaItem) { return false; } return uniqueMap[`${mediaItem.platform}`]?.has(`${mediaItem.id}`) || false; } function add(mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) { if (!mediaItem) { return; } const _mediaItem = Array.isArray(mediaItem) ? mediaItem : [mediaItem]; _mediaItem.forEach((item) => { if (!uniqueMap[`${item.platform}`]) { uniqueMap[`${item.platform}`] = new Set([`${item.id}`]); } else { uniqueMap[`${item.platform}`].add(`${item.id}`); } }); } function remove(mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) { if (!mediaItem) { return; } const _mediaItem = Array.isArray(mediaItem) ? mediaItem : [mediaItem]; _mediaItem.forEach((item) => { if (uniqueMap[`${item.platform}`]) { uniqueMap[`${item.platform}`].delete(`${item.id}`); } }); } return { getMap, add, has, remove, }; } ================================================ FILE: src/common/void-callback.ts ================================================ // eslint-disable-next-line @typescript-eslint/no-empty-function export default () => {}; ================================================ FILE: src/hooks/useAppConfig.ts ================================================ import AppConfig from "@shared/app-config/renderer"; import { IAppConfig } from "@/types/app-config"; import { useEffect, useState } from "react"; export default function useAppConfig(configKey: K): IAppConfig[K] { const [state, setState] = useState(AppConfig.getConfig(configKey)); useEffect(() => { const callback = (patch: IAppConfig, fullConfig: IAppConfig) => { if (configKey in patch) { setState(fullConfig[configKey]); } }; AppConfig.onConfigUpdate(callback); return () => { AppConfig.offConfigUpdate(callback); }; }, []); return state; } ================================================ FILE: src/hooks/useLocalFonts.ts ================================================ import Store from "@/common/store"; import { useEffect } from "react"; const fontsStore = new Store(null); async function initFonts() { if (fontsStore.getValue()) { return fontsStore.getValue(); } try { const allFonts = await window.queryLocalFonts(); fontsStore.setValue(allFonts); return allFonts; } catch (e) { console.log(e); } return null; } export default function useLocalFonts() { useEffect(() => { initFonts(); }, []); return fontsStore.useValue(); } ================================================ FILE: src/hooks/useMediaDevices.ts ================================================ import { useEffect, useState } from "react"; export function useOutputAudioDevices() { const [devices, setDevices] = useState(null); useEffect(() => { navigator.mediaDevices .enumerateDevices() .then((res) => { setDevices(res.filter((item) => item.kind === "audiooutput")); }) .catch(() => {}) .finally(() => {}); }, []); return devices; } ================================================ FILE: src/hooks/useMounted.ts ================================================ import { useEffect, useRef } from "react"; export default function useMounted(){ const isMounted = useRef(false); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; }; }, []); return isMounted; } ================================================ FILE: src/hooks/useStateRef.ts ================================================ import { useRef, useState } from "react"; export default function useStateRef(initValue: T) { const [state, setState] = useState(initValue); const ref = useRef(initValue); ref.current = state; return [state, setState, ref] as const; } ================================================ FILE: src/hooks/useVirtualList.ts ================================================ import { MutableRefObject, useCallback, useEffect, useRef, useState, } from "react"; import throttle from "lodash.throttle"; interface IVirtualListProps { /** 滚动的容器 */ getScrollElement?: () => HTMLElement; /** 滚动容器的query */ scrollElementQuery?: string; /** 元素高度和列表高度 */ estimateItemHeight: number; /** 数据 */ data: T[]; /** 渲染数目 */ renderCount?: number; /** 虚拟列表失效时的渲染数目 */ fallbackRenderCount?: number; /** 偏移高度 */ offsetHeight?: number | (() => number); } interface IVirtualItem { /** 偏移 */ top: number; /** 下标 */ rowIndex: number; /** 数据 */ dataItem: T; } export default function useVirtualList(props: IVirtualListProps) { const { estimateItemHeight, data, renderCount = 40, fallbackRenderCount = -1, getScrollElement, scrollElementQuery, offsetHeight = 0, } = props; const dataRef = useRef(data); dataRef.current = data; const [virtualItems, setVirtualItems] = useState[]>([]); const [totalHeight, setTotalHeight] = useState( data.length * estimateItemHeight, ); const scrollElementRef = useRef(); const scrollHandler = useCallback( throttle( () => { const scrollTop = (scrollElementRef.current?.scrollTop ?? 0) - (typeof offsetHeight === "number" ? offsetHeight : offsetHeight()); const realData = dataRef.current; const estimizeStartIndex = Math.floor(scrollTop / estimateItemHeight); const startIndex = Math.max( estimizeStartIndex - (estimizeStartIndex % 2 === 1 ? 3 : 2), 0, ); setVirtualItems( realData .slice( startIndex, startIndex + (scrollElementRef.current ? renderCount : fallbackRenderCount < 0 ? realData.length : fallbackRenderCount), ) .map((item, index) => ({ rowIndex: startIndex + index, dataItem: item, top: (startIndex + index) * estimateItemHeight, })), ); }, 32, { trailing: true, leading: true, }, ), [], ); useEffect(() => { setTotalHeight(data.length * estimateItemHeight); scrollHandler(); }, [data]); useEffect(() => { if (!scrollElementRef.current) { scrollElementRef.current = getScrollElement ? getScrollElement() : document.querySelector(scrollElementQuery); } if (scrollElementRef.current) { scrollElementRef.current.addEventListener("scroll", scrollHandler); } return () => { scrollElementRef.current?.removeEventListener?.("scroll", scrollHandler); scrollElementRef.current = null; }; }, []); function setScrollElement(scrollElement: HTMLElement) { scrollElementRef.current?.removeEventListener("scroll", scrollHandler); scrollElementRef.current = scrollElement; if (scrollElement) { scrollElement.addEventListener("scroll", scrollHandler); scrollHandler(); } } function scrollToIndex(index: number, behavior?: ScrollBehavior) { scrollElementRef.current.scrollTo({ top: (typeof offsetHeight === "number" ? offsetHeight : offsetHeight()) + estimateItemHeight * index, behavior, }); } return { virtualItems, totalHeight, startTop: virtualItems[0]?.top ?? 0, setScrollElement, scrollToIndex, }; } ================================================ FILE: src/main/deep-link/index.ts ================================================ import { supportLocalMediaType } from "@/common/constant"; import { parseLocalMusicItem, safeStat } from "@/common/file-util"; import PluginManager from "@shared/plugin-manager/main"; import voidCallback from "@/common/void-callback"; import messageBus from "@shared/message-bus/main"; export function handleDeepLink(url: string) { if (!url) { return; } try { const urlObj = new URL(url); if (urlObj.protocol === "musicfree:") { handleMusicFreeScheme(urlObj); } } catch { // pass } } async function handleMusicFreeScheme(url: URL) { const hostname = url.hostname; if (hostname === "install") { try { const pluginUrlStr = url.pathname.slice(1) || url.searchParams.get("plugin"); const pluginUrls = pluginUrlStr.split(",").map(decodeURIComponent); await Promise.all( pluginUrls.map((it) => PluginManager.installPluginFromRemoteUrl(it).catch(voidCallback)), ); } catch { // pass } } } async function handleBareUrl(url: string) { try { if ( (await safeStat(url)).isFile() && supportLocalMediaType.some((postfix) => url.endsWith(postfix)) ) { const musicItem = await parseLocalMusicItem(url); messageBus.sendCommand("PlayMusic", musicItem); } } catch { } } ================================================ FILE: src/main/index.ts ================================================ import { app, BrowserWindow, globalShortcut } from "electron"; import fs from "fs"; import path from "path"; import { setAutoFreeze } from "immer"; import { setupGlobalContext } from "@/shared/global-context/main"; import { setupI18n } from "@/shared/i18n/main"; import { handleDeepLink } from "./deep-link"; import logger from "@shared/logger/main"; import { PlayerState } from "@/common/constant"; import ThumbBarUtil from "@/common/thumb-bar-util"; import windowManager from "@main/window-manager"; import AppConfig from "@shared/app-config/main"; import TrayManager from "@main/tray-manager"; import WindowDrag from "@shared/window-drag/main"; import { IAppConfig } from "@/types/app-config"; import axios from "axios"; import { HttpsProxyAgent } from "https-proxy-agent"; import PluginManager from "@shared/plugin-manager/main"; import ServiceManager from "@shared/service-manager/main"; import utils from "@shared/utils/main"; import messageBus from "@shared/message-bus/main"; import shortCut from "@shared/short-cut/main"; import voidCallback from "@/common/void-callback"; // portable if (process.platform === "win32") { try { const appPath = app.getPath("exe"); const portablePath = path.resolve(appPath, "../portable"); const portableFolderStat = fs.statSync(portablePath); if (portableFolderStat.isDirectory()) { const appPathNames = ["appData", "userData"]; appPathNames.forEach((it) => { app.setPath(it, path.resolve(portablePath, it)); }); } } catch (e) { // pass } } setAutoFreeze(false); if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient("musicfree", process.execPath, [ path.resolve(process.argv[1]), ]); } } else { app.setAsDefaultProtocolClient("musicfree"); } // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("activate", () => { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { windowManager.showMainWindow(); } }); if (!app.requestSingleInstanceLock()) { app.exit(0); } app.on("second-instance", (_evt, commandLine) => { if (windowManager.mainWindow) { windowManager.showMainWindow(); } if (process.platform !== "darwin") { handleDeepLink(commandLine.pop()); } }); app.on("open-url", (_evt, url) => { handleDeepLink(url); }); app.on("will-quit", () => { globalShortcut.unregisterAll(); }); // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and import them here. app.whenReady().then(async () => { logger.logPerf("App Ready"); setupGlobalContext(); await AppConfig.setup(windowManager); await setupI18n({ getDefaultLang() { return AppConfig.getConfig("normal.language"); }, onLanguageChanged(lang) { AppConfig.setConfig({ "normal.language": lang, }); if (process.platform === "win32") { ThumbBarUtil.setThumbBarButtons(windowManager.mainWindow, messageBus.getAppState().playerState === PlayerState.Playing); } }, }); utils.setup(windowManager); PluginManager.setup(windowManager); TrayManager.setup(windowManager); WindowDrag.setup(); shortCut.setup().then(voidCallback); logger.logPerf("Create Main Window"); // Setup message bus & app state messageBus.onAppStateChange((_, patch) => { if ("musicItem" in patch) { TrayManager.buildTrayMenu(); const musicItem = patch.musicItem; const mainWindow = windowManager.mainWindow; if (mainWindow) { const thumbStyle = AppConfig.getConfig("normal.taskbarThumb"); if (process.platform === "win32" && thumbStyle === "artwork") { ThumbBarUtil.setThumbImage(mainWindow, musicItem?.artwork); } if (musicItem) { mainWindow.setTitle( musicItem.title + (musicItem.artist ? ` - ${musicItem.artist}` : ""), ); } else { mainWindow.setTitle(app.name); } } } else if ("playerState" in patch) { TrayManager.buildTrayMenu(); const playerState = patch.playerState; if (process.platform === "win32") { ThumbBarUtil.setThumbBarButtons(windowManager.mainWindow, playerState === PlayerState.Playing); } } else if ("repeatMode" in patch) { TrayManager.buildTrayMenu(); } else if ("lyricText" in patch && process.platform === "darwin") { if (AppConfig.getConfig("lyric.enableStatusBarLyric")) { TrayManager.setTitle(patch.lyricText); } else { TrayManager.setTitle(""); } } }); messageBus.setup(windowManager); windowManager.showMainWindow(); bootstrap(); }); async function bootstrap() { ServiceManager.setup(windowManager); const downloadPath = AppConfig.getConfig("download.path"); if (!downloadPath) { AppConfig.setConfig({ "download.path": app.getPath("downloads"), }); } const minimodeEnabled = AppConfig.getConfig("private.minimode"); if (minimodeEnabled) { windowManager.showMiniModeWindow(); } /** 一些初始化设置 */ // 初始化桌面歌词 const desktopLyricEnabled = AppConfig.getConfig("lyric.enableDesktopLyric"); if (desktopLyricEnabled) { windowManager.showLyricWindow(); } AppConfig.onConfigUpdated((patch) => { // 桌面歌词锁定状态 if ("lyric.lockLyric" in patch) { const lyricWindow = windowManager.lyricWindow; const lockState = patch["lyric.lockLyric"]; if (!lyricWindow) { return; } if (lockState) { lyricWindow.setIgnoreMouseEvents(true, { forward: true, }); } else { lyricWindow.setIgnoreMouseEvents(false); } } if ("shortCut.enableGlobal" in patch) { const enableGlobal = patch["shortCut.enableGlobal"]; if (enableGlobal) { shortCut.registerAllGlobalShortCuts(); } else { shortCut.unregisterAllGlobalShortCuts(); } } }); // 初始化代理 const proxyConfigKeys: Array = [ "network.proxy.enabled", "network.proxy.host", "network.proxy.port", "network.proxy.username", "network.proxy.password", ]; AppConfig.onConfigUpdated((patch, config) => { let proxyUpdated = false; for (const proxyConfigKey of proxyConfigKeys) { if (proxyConfigKey in patch) { proxyUpdated = true; break; } } if (proxyUpdated) { if (config["network.proxy.enabled"]) { handleProxy(true, config["network.proxy.host"], config["network.proxy.port"], config["network.proxy.username"], config["network.proxy.password"]); } else { handleProxy(false); } } }); handleProxy( AppConfig.getConfig("network.proxy.enabled"), AppConfig.getConfig("network.proxy.host"), AppConfig.getConfig("network.proxy.port"), AppConfig.getConfig("network.proxy.username"), AppConfig.getConfig("network.proxy.password"), ); } function handleProxy(enabled: boolean, host?: string | null, port?: string | null, username?: string | null, password?: string | null) { try { if (!enabled) { axios.defaults.httpAgent = undefined; axios.defaults.httpsAgent = undefined; } else if (host) { const proxyUrl = new URL(host); proxyUrl.port = port; proxyUrl.username = username; proxyUrl.password = password; const agent = new HttpsProxyAgent(proxyUrl); axios.defaults.httpAgent = agent; axios.defaults.httpsAgent = agent; } else { throw new Error("Unknown Host"); } } catch (e) { axios.defaults.httpAgent = undefined; axios.defaults.httpsAgent = undefined; } } ================================================ FILE: src/main/native_modules/TaskbarThumbnailManager/TaskbarThumbnailManager.node.d.ts ================================================ declare module "@native/TaskbarThumbnailManager/TaskbarThumbnailManager.node" { interface ISize { width: number; height: number; } function config(hWnd: bigint): void; function sendIconicRepresentation(hWnd: bigint, size: ISize, buf: Buffer); function sendLivePreviewBitmap(hWnd: bigint, size: ISize, buf: Buffer); } ================================================ FILE: src/main/tray-manager/index.ts ================================================ import { app, Menu, MenuItem, MenuItemConstructorOptions, nativeImage, Tray } from "electron"; import { t } from "@shared/i18n/main"; import { IWindowManager } from "@/types/main/window-manager"; import getResourcePath from "@/common/get-resource-path"; import { PlayerState, RepeatMode, ResourceName } from "@/common/constant"; import AppConfig from "@shared/app-config/main"; import windowManager from "@main/window-manager"; import { IAppConfig } from "@/types/app-config"; import messageBus from "@shared/message-bus/main"; if (process.platform === "darwin") { Menu.setApplicationMenu( Menu.buildFromTemplate([ { label: app.getName(), submenu: [ { label: t("common.about"), role: "about", }, { label: t("common.exit"), click() { app.quit(); }, }, ], }, { label: t("common.edit"), submenu: [ { label: t("common.undo"), accelerator: "Command+Z", role: "undo", }, { label: t("common.redo"), accelerator: "Shift+Command+Z", role: "redo", }, { type: "separator" }, { label: t("common.cut"), accelerator: "Command+X", role: "cut" }, { label: t("common.copy"), accelerator: "Command+C", role: "copy" }, { label: t("common.cut"), accelerator: "Command+V", role: "paste" }, { type: "separator" }, { label: t("common.select_all"), accelerator: "Command+A", role: "selectAll", }, ], }, ]), ); } else { Menu.setApplicationMenu(null); } class TrayManager { private static trayInstance: Tray | null = null; private windowManager: IWindowManager; private observedKey: Array = [ "lyric.lockLyric", "lyric.enableDesktopLyric", "normal.language", ]; public setup(windowManager: IWindowManager) { this.windowManager = windowManager; const tray = new Tray( nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)).resize({ width: 32, height: 32, }), ); if (process.platform === "linux") { tray.on("click", () => { windowManager.showMainWindow(); }); } else { tray.on("double-click", () => { windowManager.showMainWindow(); }); } let debugClickCount = 0; let debugClickTime = 0; const debugModeHandler = () => { const now = Date.now(); if (now - debugClickTime < 500) { debugClickCount++; debugClickTime = now; if (debugClickCount === 5) { windowManager.getAllWindows().forEach(win => { win?.webContents?.openDevTools({ mode: "undocked", }); }); } } else { debugClickCount = 1; debugClickTime = Date.now(); } }; tray.on("click", debugModeHandler); // 配置变化时更新菜单 AppConfig.onConfigUpdated((changedConfig) => { for (const k of this.observedKey) { if (k in changedConfig) { this.buildTrayMenu(); return; } } }); TrayManager.trayInstance = tray; this.buildTrayMenu(); } private openMusicDetail() { this.windowManager.showMainWindow(); messageBus.sendCommand("OpenMusicDetailPage"); } public async buildTrayMenu() { if (!TrayManager.trayInstance) { return; } const ctxMenu: Array = []; const tray = TrayManager.trayInstance; /********* 音乐信息 **********/ const { musicItem, playerState, repeatMode } = messageBus.getAppState(); // 更新一下tooltip if (musicItem) { tray.setToolTip( `${musicItem.title ?? t("media.unknown_title")}${musicItem.artist ? ` - ${musicItem.artist}` : "" }`, ); } else { tray.setToolTip("MusicFree"); } if (musicItem) { const fullName = `${musicItem.title ?? t("media.unknown_title")}${musicItem.artist ? ` - ${musicItem.artist}` : "" }`; ctxMenu.push( { label: fullName.length > 12 ? fullName.slice(0, 12) + "..." : fullName, click: this.openMusicDetail.bind(this), }, { label: `${t("media.media_platform")}: ${musicItem.platform}`, click: this.openMusicDetail.bind(this), }, ); } else { ctxMenu.push({ label: t("main.no_playing_music"), enabled: false, }); } ctxMenu.push( { label: musicItem ? playerState === PlayerState.Playing ? t("media.music_state_pause") : t("media.music_state_play") : t("media.music_state_play_or_pause"), enabled: !!musicItem, click() { if (!musicItem) { return; } messageBus.sendCommand("TogglePlayerState"); }, }, { label: t("main.previous_music"), enabled: !!musicItem, click() { messageBus.sendCommand("SkipToPrevious"); }, }, { label: t("main.next_music"), enabled: !!musicItem, click() { messageBus.sendCommand("SkipToNext"); }, }, ); ctxMenu.push({ label: t("media.music_repeat_mode"), type: "submenu", submenu: Menu.buildFromTemplate([ { label: t("media.music_repeat_mode_loop"), id: RepeatMode.Loop, type: "radio", checked: repeatMode === RepeatMode.Loop, click() { messageBus.sendCommand("SetRepeatMode", RepeatMode.Loop); }, }, { label: t("media.music_repeat_mode_queue"), id: RepeatMode.Queue, type: "radio", checked: repeatMode === RepeatMode.Queue, click() { messageBus.sendCommand("SetRepeatMode", RepeatMode.Queue); }, }, { label: t("media.music_repeat_mode_shuffle"), id: RepeatMode.Shuffle, type: "radio", checked: repeatMode === RepeatMode.Shuffle, click() { messageBus.sendCommand("SetRepeatMode", RepeatMode.Shuffle); }, }, ]), }); ctxMenu.push({ type: "separator", }); /** TODO: 桌面歌词 */ // const lyricConfig = await getAppConfigPath("lyric"); // if (lyricConfig?.enableDesktopLyric) { // ctxMenu.push({ // label: t("main.close_desktop_lyric"), // click() { // setLyricWindow(false); // }, // }); // } else { // ctxMenu.push({ // label: t("main.open_desktop_lyric"), // click() { // setLyricWindow(true); // }, // }); // } // // if (lyricConfig?.lockLyric) { // ctxMenu.push({ // label: t("main.unlock_desktop_lyric"), // click() { // setDesktopLyricLock(false); // }, // }); // } else { // ctxMenu.push({ // label: t("main.lock_desktop_lyric"), // click() { // setDesktopLyricLock(true); // }, // }); // } ctxMenu.push({ type: "separator", }); /********* 其他操作 **********/ ctxMenu.push({ label: t("app_header.settings"), click() { windowManager.showMainWindow(); messageBus.sendCommand("Navigate", "/main/setting"); }, }); ctxMenu.push({ label: t("common.exit"), role: process.platform === "win32" ? undefined : "quit", click() { windowManager.mainWindow?.removeAllListeners?.(); app.exit(0); }, }); TrayManager.trayInstance.setContextMenu(Menu.buildFromTemplate(ctxMenu)); } public setTitle(title: string) { if (!title || !title.length) { TrayManager.trayInstance?.setTitle(""); return; } if (title.length > 7) { TrayManager.trayInstance?.setTitle(" " + title.slice(0) + "..."); } else { TrayManager.trayInstance?.setTitle(" " + title); } } } export default new TrayManager(); ================================================ FILE: src/main/window-manager/index.ts ================================================ import { app, BrowserWindow, nativeImage, screen } from "electron"; import getResourcePath from "@/common/get-resource-path"; import { IWindowEvents, IWindowManager } from "@/types/main/window-manager"; import { localPluginName, PlayerState, ResourceName } from "@/common/constant"; import voidCallback from "@/common/void-callback"; import ThumbBarUtil from "@/common/thumb-bar-util"; import EventEmitter from "eventemitter3"; import WindowDrag from "@shared/window-drag/main"; import AppConfig from "@shared/app-config/main"; import messageBus from "@shared/message-bus/main"; import { IAppConfig } from "@/types/app-config"; import debounce from "@/common/debounce"; // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on // whether you're running in development or production). declare const MAIN_WINDOW_WEBPACK_ENTRY: string; declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string; declare const LRC_WINDOW_WEBPACK_ENTRY: string; declare const LRC_WINDOW_PRELOAD_WEBPACK_ENTRY: string; declare const MINIMODE_WINDOW_WEBPACK_ENTRY: string; declare const MINIMODE_WINDOW_PRELOAD_WEBPACK_ENTRY: string; class WindowManager implements IWindowManager { private static mainWindow: BrowserWindow | null = null; private static lrcWindow: BrowserWindow | null = null; private static miniModeWindow: BrowserWindow | null = null; private ee: EventEmitter = new EventEmitter(); getMainWindow(): BrowserWindow { return WindowManager.mainWindow; } get mainWindow() { return WindowManager.mainWindow; } get lyricWindow() { return WindowManager.lrcWindow; } get miniModeWindow() { return WindowManager.miniModeWindow; } getExtensionWindows(): BrowserWindow[] { const extWindows = []; if (WindowManager.lrcWindow) { extWindows.push(WindowManager.lrcWindow); } if (WindowManager.miniModeWindow) { extWindows.push(WindowManager.miniModeWindow); } return extWindows; } getAllWindows(): BrowserWindow[] { const windows = []; if (WindowManager.mainWindow) { windows.push(WindowManager.mainWindow); } if (WindowManager.lrcWindow) { windows.push(WindowManager.lrcWindow); } if (WindowManager.miniModeWindow) { windows.push(WindowManager.miniModeWindow); } return windows; } private emit(event: T, data: IWindowEvents[T]) { this.ee.emit(event, data); } public on(event: T, listener: (data: IWindowEvents[T]) => void) { this.ee.on(event, listener); } /**************************** Main Window ***************************/ private createMainWindow() { // 清理旧窗口 if (WindowManager.mainWindow) { WindowManager.mainWindow.removeAllListeners(); if (!WindowManager.mainWindow.isDestroyed()) { WindowManager.mainWindow.close(); WindowManager.mainWindow.destroy(); } WindowManager.mainWindow = null; } // 1. 创建主窗口 const initSize = AppConfig.getConfig("private.mainWindowSize"); const mainWindow = new BrowserWindow({ height: initSize?.height ?? 700, width: initSize?.width ?? 1050, minHeight: 700, minWidth: 1050, webPreferences: { preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, nodeIntegration: true, nodeIntegrationInWorker: true, webSecurity: false, sandbox: false, webviewTag: true, }, frame: false, icon: nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)), }); const updateWindowSizeConfig = debounce(() => { const [wWidth, wHeight] = mainWindow.getSize(); AppConfig.setConfig({ "private.mainWindowSize": { width: wWidth, height: wHeight, }, }); }, 300, { leading: false, trailing: true, }); mainWindow.on("resize", updateWindowSizeConfig); // 2. 加载主界面 const initUrl = new URL(MAIN_WINDOW_WEBPACK_ENTRY); initUrl.hash = `/main/musicsheet/${localPluginName}/favorite`; mainWindow.loadURL(initUrl.toString()).then(voidCallback); // 3. 开发者工具 if (!app.isPackaged) { mainWindow.on("ready-to-show", () => { mainWindow.webContents.openDevTools(); }); } // 4. 主窗口http hack逻辑 mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { /** hack headers */ try { const url = new URL(details.url); const setHeadersOptions = url.searchParams.get("_setHeaders"); if (!setHeadersOptions) { throw new Error("No Need To Hack"); } const originalRequestHeaders = details.requestHeaders ?? {}; let requestHeaders: Record = {}; if (setHeadersOptions) { const decodedHeaders = JSON.parse( decodeURIComponent(setHeadersOptions), ); for (const k in originalRequestHeaders) { requestHeaders[k.toLowerCase()] = originalRequestHeaders[k]; } for (const k in decodedHeaders) { requestHeaders[k.toLowerCase()] = decodedHeaders[k]; } } else { requestHeaders = details.requestHeaders; } callback({ requestHeaders, }); } catch { callback({ requestHeaders: details.requestHeaders, }); } }, ); mainWindow.on("close", (e) => { if (process.platform === "win32" && AppConfig.getConfig("normal.closeBehavior") === "minimize") { e.preventDefault(); mainWindow.hide(); mainWindow.setSkipTaskbar(true); } }); // 5. 更新thumbbar ThumbBarUtil.setThumbBarButtons(mainWindow, false); WindowManager.mainWindow = mainWindow; // 6. 发出信号 this.emit("WindowCreated", { windowName: "main", browserWindow: mainWindow, }); } public showMainWindow() { if (!WindowManager.mainWindow || WindowManager.mainWindow.isDestroyed()) { this.createMainWindow(); } const mainWindow = WindowManager.mainWindow; if (mainWindow.isMinimized()) { mainWindow.restore(); } else if (mainWindow.isVisible()) { mainWindow.focus(); } else { mainWindow.show(); } mainWindow.moveTop(); mainWindow.setSkipTaskbar(false); if (process.platform === "win32") { const appState = messageBus.getAppState(); ThumbBarUtil.setThumbBarButtons(mainWindow, appState.playerState === PlayerState.Playing); } } public closeMainWindow() { WindowManager.mainWindow.close(); WindowManager.mainWindow = null; } /**************************** Lyric Window ***************************/ private static lyricWindowMinSize: ICommon.ISize = { width: 920, height: 92, // 60 + 16 * 2 }; private static lyricWindowMaxSize: ICommon.ISize = { width: Infinity, height: 240, // 60 + 80 * 2 }; private formatLyricWindowSize(width?: number, height?: number): ICommon.ISize { return { width: Math.min(Math.max(width ?? Infinity, WindowManager.lyricWindowMinSize.width), WindowManager.lyricWindowMaxSize.width), height: Math.min(Math.max(height ?? Infinity, WindowManager.lyricWindowMinSize.height), WindowManager.lyricWindowMaxSize.height), }; } private evaluateWindowHeight() { const fontSize = AppConfig.getConfig("lyric.fontSize") || 54; return 60 + fontSize * 2; } private createLyricWindow() { const initPosition = AppConfig.getConfig("private.lyricWindowPosition"); const initSize = AppConfig.getConfig("private.lyricWindowSize"); let { width, height, } = this.formatLyricWindowSize(initSize?.width ?? WindowManager.lyricWindowMinSize.width, this.evaluateWindowHeight()); const lyricWindow = new BrowserWindow({ height, width, x: initPosition?.x, y: initPosition?.y, transparent: true, webPreferences: { preload: LRC_WINDOW_PRELOAD_WEBPACK_ENTRY, nodeIntegration: true, webSecurity: false, sandbox: false, }, minWidth: WindowManager.lyricWindowMinSize.width, minHeight: WindowManager.lyricWindowMinSize.height, maxHeight: WindowManager.lyricWindowMaxSize.height, resizable: true, frame: false, skipTaskbar: true, alwaysOnTop: true, icon: nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)), }); const display = screen.getDisplayNearestPoint(lyricWindow.getBounds()); WindowManager.lyricWindowMaxSize.width = display.bounds.width; lyricWindow.setMaximumSize(WindowManager.lyricWindowMaxSize.width, WindowManager.lyricWindowMaxSize.height); // and load the index.html of the app. lyricWindow.loadURL(LRC_WINDOW_WEBPACK_ENTRY); if (!app.isPackaged) { // Open the DevTools. lyricWindow.webContents.openDevTools(); } // 设置窗口可拖拽 WindowDrag.setWindowDraggable(lyricWindow, { width, // 实际不生效 height, // 实际不生效 getWindowSize() { return { width, height, }; }, onDragEnd(point) { AppConfig.setConfig({ "private.lyricWindowPosition": point, }); const currentDisplayBounds = screen.getDisplayNearestPoint(point).bounds; if (currentDisplayBounds.width !== WindowManager.lyricWindowMaxSize.width) { WindowManager.lyricWindowMaxSize.width = currentDisplayBounds.width; lyricWindow.setMaximumSize(WindowManager.lyricWindowMaxSize.width, WindowManager.lyricWindowMaxSize.height); } }, }); const updateCallback = (patch: IAppConfig, _: any, from: "main" | "renderer") => { if (from === "renderer") { if (patch["lyric.fontSize"]) { height = this.evaluateWindowHeight(); lyricWindow.setSize(width, height); } } }; AppConfig.onConfigUpdated(updateCallback); lyricWindow.on("closed", () => { AppConfig.offConfigUpdated(updateCallback); }); lyricWindow.on("resize", () => { const [wWidth, wHeight] = lyricWindow.getSize(); const fontSize = Math.max(Math.min(Math.floor((height - 60) / 2), 80), 16); AppConfig.setConfig({ "lyric.fontSize": fontSize, "private.lyricWindowSize": { width, height, }, }); width = wWidth; height = wHeight; }); // 初始化设置 lyricWindow.once("ready-to-show", async () => { const position = AppConfig.getConfig("private.lyricWindowPosition"); if (position) { this.normalizeWindowPosition(lyricWindow, position, async (position) => { AppConfig.setConfig({ "private.lyricWindowPosition": position, }); }); } const lockState = AppConfig.getConfig("lyric.lockLyric"); if (lockState) { lyricWindow.setIgnoreMouseEvents(true, { forward: true, }); } }); if (process.platform === "darwin") { // @ts-ignore ignore error in windows legacy lyricWindow.invalidateShadow(); } WindowManager.lrcWindow = lyricWindow; this.emit("WindowCreated", { windowName: "lyric", browserWindow: lyricWindow, }); } public showLyricWindow() { if (!WindowManager.lrcWindow) { this.createLyricWindow(); } const lrcWindow = WindowManager.lrcWindow; lrcWindow.show(); AppConfig.setConfig({ "lyric.enableDesktopLyric": true, }); } public closeLyricWindow() { WindowManager.lrcWindow?.close(); WindowManager.lrcWindow = null; AppConfig.setConfig({ "lyric.enableDesktopLyric": false, }); } /**************************** MiniMode Window ***************************/ private createMiniModeWindow() { // Create the browser window. const width = 340; const height = 72; const initPosition = AppConfig.getConfig("private.minimodeWindowPosition"); const miniWindow = new BrowserWindow({ height, width, x: initPosition?.x, y: initPosition?.y, webPreferences: { preload: MINIMODE_WINDOW_PRELOAD_WEBPACK_ENTRY, nodeIntegration: true, nodeIntegrationInWorker: true, webSecurity: false, sandbox: false, }, resizable: false, frame: false, skipTaskbar: true, alwaysOnTop: true, }); // and load the index.html of the app. const initUrl = new URL(MINIMODE_WINDOW_WEBPACK_ENTRY); miniWindow.loadURL(initUrl.toString()); if (!app.isPackaged) { miniWindow.on("ready-to-show", () => { // Open the DevTools. miniWindow.webContents.openDevTools(); }); } WindowDrag.setWindowDraggable(miniWindow, { width, height, onDragEnd(point) { AppConfig.setConfig({ "private.minimodeWindowPosition": point, }); }, }); miniWindow.once("ready-to-show", () => { const position = AppConfig.getConfig("private.minimodeWindowPosition"); if (position) { this.normalizeWindowPosition(miniWindow, position, async (position) => { AppConfig.setConfig({ "private.minimodeWindowPosition": position, }); }); } }); WindowManager.miniModeWindow = miniWindow; this.emit("WindowCreated", { windowName: "minimode", browserWindow: miniWindow, }); } public showMiniModeWindow() { if (!WindowManager.miniModeWindow) { this.createMiniModeWindow(); } const miniWindow = WindowManager.miniModeWindow; if (miniWindow.isMinimized()) { miniWindow.restore(); } else if (miniWindow.isVisible()) { miniWindow.focus(); } else { miniWindow.show(); } miniWindow.moveTop(); miniWindow.setSkipTaskbar(false); AppConfig.setConfig({ "private.minimode": true, }); } public closeMiniModeWindow() { WindowManager.miniModeWindow?.close(); WindowManager.miniModeWindow = null; AppConfig.setConfig({ "private.minimode": false, }); } private normalizeWindowPosition(window: BrowserWindow, position: ICommon.IPoint, onNormalized: (position: ICommon.IPoint) => void) { const currentDisplayBounds = screen.getDisplayNearestPoint(position).bounds; const windowBounds = window.getBounds(); // 如果完全在是窗外,重置位置 const [left, top, right, bottom] = [ position.x, position.y, position.x + windowBounds.width, position.y + windowBounds.height, ]; let needMakeup = false; if (left > currentDisplayBounds.x + currentDisplayBounds.width) { position.x = currentDisplayBounds.x + currentDisplayBounds.width - windowBounds.width; needMakeup = true; } else if (right < currentDisplayBounds.x) { position.x = currentDisplayBounds.x; needMakeup = true; } if (top > currentDisplayBounds.y + currentDisplayBounds.height) { position.y = currentDisplayBounds.y + currentDisplayBounds.height - windowBounds.height; needMakeup = true; } else if (bottom < currentDisplayBounds.y) { position.y = currentDisplayBounds.y; needMakeup = true; } window.setPosition(position.x, position.y, false); if (needMakeup) { onNormalized(position); } } } export default new WindowManager(); ================================================ FILE: src/preload/common-preload.ts ================================================ // See the Electron documentation for details on how to use preload scripts: import { contextBridge } from "electron"; import path from "path"; import "electron-log/preload"; import "@shared/i18n/preload"; import "@shared/global-context/preload"; import "@shared/themepack/preload"; import "@shared/app-config/preload"; import "@shared/utils/preload"; import "@shared/window-drag/preload"; // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts contextBridge.exposeInMainWorld("path", path); ================================================ FILE: src/preload/extension.ts ================================================ import "./common-preload"; import "@/shared/message-bus/preload/extension"; ================================================ FILE: src/preload/index.ts ================================================ // See the Electron documentation for details on how to use preload scripts: import "./common-preload"; // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts import "@shared/service-manager/preload"; import "@shared/plugin-manager/preload"; import "@shared/message-bus/preload/main"; import "@shared/short-cut/preload"; ================================================ FILE: src/renderer/app.scss ================================================ .app-container { width: 100vw; height: 100vh; display: flex; flex-direction: column; overflow: hidden; position: relative; & .body-container { width: 100%; height: 0; flex: 1; display: flex; position: relative; } } .tab-list-container { display: flex; margin-top: 1.2rem; flex-shrink: 0; column-gap: 2.5rem; overflow-x: auto; padding-bottom: 6px; &::-webkit-scrollbar { display: none; } /* 当鼠标悬浮时显示滚动条 */ &:hover { &::-webkit-scrollbar { display: block; } } & .tab-list-item { display: flex; align-items: center; justify-content: center; position: relative; font-size: 1.1rem; padding: 0 0 0.5rem; outline: none; cursor: pointer; opacity: 0.7; white-space: nowrap; user-select: none; &:hover { opacity: 0.9; } &[data-headlessui-state="selected"] { font-weight: 600; opacity: 1; &::after { content: ""; position: absolute; width: 70%; min-width: 2rem; height: 4px; border-radius: 2px; left: 50%; bottom: 0; background-color: var(--primaryColor); transform: translateX(-50%); } } } } .tab-panels-container { flex: 1; & .tab-panel-container { height: 100%; outline: none; } } // 动态主题 :root { --primaryColor: #f17d34; // 主色调 --backgroundColor: #fdfdfd; // 背景色 --dividerColor: rgba(0, 0, 0, 0.1); // 分割线颜色 --listEvenColor: rgba(0, 0, 0, 0.05); // 列表背景色(奇数条目) --listHoverColor: rgba(0, 0, 0, 0.05); // 列表悬浮颜色 --listActiveColor: rgba(0, 0, 0, 0.1); // 列表选中颜色 --textColor: #333333; // 主文本颜色 --maskColor: rgba(51, 51, 51, 0.5); // 遮罩层颜色 /* --backdropColor: #fdfdfd; // 弹窗等地方的背景颜色,默认和背景色一致*/ --shadowColor: rgba(0, 0, 0, 0.2); /** --shadow: // 全部的shadow属性 */ --placeholderColor: #f4f4f4; --successColor: #08a34c; --dangerColor: #fc5f5f; --infoColor: #0a95c8; --linkColor: #0c66fc; /* --headerPlaceholderColor: rgba($color: #000, $alpha: 0.14); */ --headerTextColor: white; /* --musicBarTextColor: #000; // 有必要再加*/ --animate-duration: 300ms !important; --scrollbarWidth: 12px; --appHeaderHeight: 54px; --appMusicBarHeight: 64px; // 基础字体 font-size: 13px; color: var(--textColor); } a { color: var(--linkColor); } ================================================ FILE: src/renderer/app.tsx ================================================ import AppHeader from "./components/Header"; import "./app.scss"; import MusicBar from "./components/MusicBar"; import { Outlet } from "react-router"; import PanelComponent from "./components/Panel"; import MusicDetail from "@renderer/components/MusicDetail"; export default function App() { return (
); } ================================================ FILE: src/renderer/components/A/index.tsx ================================================ import { shellUtil } from "@shared/utils/renderer"; export default function A( props: React.DetailedHTMLProps< React.AnchorHTMLAttributes, HTMLAnchorElement >, ) { return ( { if (props.href) { shellUtil.openExternal(props.href); } props?.onClick?.(...args); }} > ); } ================================================ FILE: src/renderer/components/AnimatedDiv/index.tsx ================================================ import React, { useMemo, useState, useEffect } from "react"; interface IProps extends React.DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement > { // 展示条件 showIf?: boolean; // 挂载动画 mountClassName?: string; // 卸载动画 unmountClassName?: string; onMountAnimationEnd?: () => void; onUnmountAnimationEnd?: () => void; } /** * 动画div组件 * @returns */ export default function AnimatedDiv(props: IProps) { const { showIf = true, mountClassName, unmountClassName, onMountAnimationEnd, onUnmountAnimationEnd, className, onAnimationEnd, } = props ?? {}; const [shouldMount, setShouldMount] = useState(false); const [_animationPlaying, setAnimationPlaying] = useState(false); const filteredProps: Record = useMemo(() => { const res = { ...(props ?? {}), } as any; delete res.showIf; delete res.mountClassName; delete res.unmountClassName; return res; }, [props]); useEffect(() => { if (showIf) { setShouldMount(true); } else if (!unmountClassName) { setShouldMount(false); } }, [showIf]); return shouldMount ? (
{ onAnimationEnd?.(...args); if (!showIf) { // 如果showIf是false,表示当前播放的是卸载状态的动画 setShouldMount(false); onUnmountAnimationEnd?.(); } else { setShouldMount(true); onMountAnimationEnd?.(); } setAnimationPlaying(prev => !prev); }} >
) : null; } ================================================ FILE: src/renderer/components/ArtistItem/index.scss ================================================ .components--artist-item-container { $width: 140px; $height: 216px; width: $width; height: $height; & .artist-img-wrapper { width: $width; height: $width; -webkit-user-drag: none; border-radius: 8px; overflow: hidden; position: relative; & .artist-play-info { position: absolute; box-sizing: border-box; left: 0; bottom: 0; background-color: rgba($color: #000000, $alpha: 0.2); backdrop-filter: blur(50px); width: 100%; height: 1.8rem; font-size: 0.85rem; display: flex; align-items: center; justify-content: space-between; color: #eee; padding-left: 4px; padding-right: 4px; white-space: nowrap; & .play-count { display: flex; align-items: center; & svg { margin-right: 2px; } } } & img { position: absolute; top: 0; left: 0; width: $width; height: $width; -webkit-user-drag: none; border-radius: 8px; object-fit: cover; transition: transform ease-out 400ms; &:hover { transform: scale(1.1); } } } & .media-info { margin-top: 6px; height: $height - $width - 6px; width: 100%; & .title { font-size: 1.1rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; &:hover { color: var(--primaryColor); } } & .desc { font-size: 0.9rem; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; opacity: 0.8; overflow: hidden; } } } ================================================ FILE: src/renderer/components/ArtistItem/index.tsx ================================================ import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import "./index.scss"; import albumImg from "@/assets/imgs/album-cover.jpg"; import { memo } from "react"; interface IArtistItemProps { artistItem: IArtist.IArtistItem; onClick?: (artistItem: IArtist.IArtistItem) => void; } function ArtistItem(props: IArtistItemProps) { const { artistItem, onClick } = props; return (
{ onClick?.(artistItem); }} >
{/*
{mediaItem?.createAt ? ( dayjs(mediaItem.createAt).format("YYYY-MM-DD") ) : (
)}
{normalizeNumber(mediaItem?.playCount)}
*/}
{artistItem?.name}
{(artistItem?.description ?? "").split("\\n").map((item, index) => (
{item}
))}
); } export default memo( ArtistItem, (prev, curr) => prev.artistItem === curr.artistItem && prev.onClick === curr.onClick, ); ================================================ FILE: src/renderer/components/BottomLoadingState/index.scss ================================================ .bottom-loading-state { width: 100%; height: 4rem; display: flex; align-items: center; justify-content: center; user-select: none; } .bottom-loading-state--reach-end { opacity: 0.8; } .bottom-loading-state--loadmore { &:hover { color: var(--primaryColor); } } .bottom-loading-state--loading { display: flex; align-items: center; } $loading-size: 20px; .lds-dual-ring { display: inline-block; width: $loading-size; height: $loading-size; margin-right: 0.5rem; } .lds-dual-ring:after { content: " "; display: block; width: $loading-size * 0.8; height: $loading-size * 0.8; margin: $loading-size * 0.1; border-radius: 50%; border: $loading-size * 0.1 solid var(--textColor); border-color: var(--textColor) transparent var(--textColor) transparent; animation: lds-dual-ring 1.2s linear infinite; } @keyframes lds-dual-ring { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } ================================================ FILE: src/renderer/components/BottomLoadingState/index.tsx ================================================ import { useEffect, useRef } from "react"; import { RequestStateCode } from "@/common/constant"; import "./index.scss"; import { useTranslation } from "react-i18next"; import AppConfig from "@shared/app-config/renderer"; interface IProps { state: RequestStateCode; onLoadMore?: () => void; } export default function BottomLoadingState(props: IProps) { const { state, onLoadMore } = props; const stateRef = useRef(state); stateRef.current = state; const containerRef = useRef(null); const { t } = useTranslation(); useEffect(() => { const intersectionObserver = new IntersectionObserver((entries) => { if(AppConfig.getConfig("normal.autoLoadMore") && stateRef.current === RequestStateCode.PARTLY_DONE && entries[0].intersectionRatio > 0) { if (onLoadMore) { onLoadMore(); } } }); if (containerRef.current) { intersectionObserver.observe(containerRef.current); } return () => { if (containerRef.current) { intersectionObserver.unobserve(containerRef.current); } }; }, [onLoadMore]); let component = null; if (state === RequestStateCode.FINISHED) { component = {t("bottom_loading_state.reached_end")}; } else if (state === RequestStateCode.PENDING_REST_PAGE) { component = <>
{t("bottom_loading_state.loading")} ; } else if (state === RequestStateCode.PARTLY_DONE) { component = {t("bottom_loading_state.load_more")} ; } return
{component}
; } ================================================ FILE: src/renderer/components/Checkbox/index.scss ================================================ .checkbox-container { width: 1rem; height: 1rem; border-radius: 2px; border: 1px solid currentColor; position: relative; & svg { position: absolute; width: 1rem; height: 1rem; left: 0; top: 0; } } ================================================ FILE: src/renderer/components/Checkbox/index.tsx ================================================ import { CSSProperties } from "react"; import SvgAsset from "../SvgAsset"; import "./index.scss"; interface ICheckboxProps { checked?: boolean; onChange?: (newChecked: boolean) => void; style?: CSSProperties } export default function Checkbox(props: ICheckboxProps) { const { checked, onChange, style } = props; return (
{ onChange?.(!checked); }} > {checked ? : null}
); } ================================================ FILE: src/renderer/components/Condition/index.tsx ================================================ import { ReactNode } from "react"; interface IConditionProps { condition: any; truthy?: ReactNode; falsy?: ReactNode; children?: ReactNode; } export default function Condition(props: IConditionProps) { const { condition, truthy, falsy, children } = props; return <>{condition ? truthy ?? children : falsy}; } interface IIfProps { condition: any; children?: any; } interface ICondProps { children?: ReactNode | ReactNode[]; } function Truthy(props: ICondProps) { return <>{props?.children}; } function Falsy(props: ICondProps) { return <>{props?.children}; } function If(props: IIfProps) { const { condition, children } = props; if (!children) { return null; } let _children: any; if (Array.isArray) { _children = children.map((it: any) => condition ? it.type !== Falsy ? it : null : it.type !== Truthy ? it : null, ); } else { _children = condition ? _children!.type !== Falsy ? _children : null : _children.type !== Truthy ? _children : null; } return _children; } If.Truthy = Truthy; If.Falsy = Falsy; function IfTruthy(props: IIfProps) { const { condition, children } = props; return condition ? children : null; } function IfFalsy(props: IIfProps) { const { condition, children } = props; return condition ? null : children; } export { If, IfTruthy, IfFalsy }; ================================================ FILE: src/renderer/components/ContextMenu/index.scss ================================================ .context-menu--single-column-container { position: fixed; overflow-y: auto; z-index: 999999; border-radius: 6px; & .divider { margin: 0; height: 1px; } & .menu-item { box-sizing: border-box; width: 100%; display: flex; align-items: center; padding-left: 8px; padding-right: 8px; position: relative; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } & .menu-item-icon { $item-size: 1.2rem; width: $item-size; height: $item-size; margin-right: 8px; & svg { width: $item-size; height: $item-size; } } & .menu-item-expand { $tag-size: 4px; border: $tag-size solid transparent; border-left-color: currentColor; position: absolute; right: 12px; } &:hover { background: var(--listHoverColor); } } } ================================================ FILE: src/renderer/components/ContextMenu/index.tsx ================================================ import Store from "@/common/store"; import SvgAsset, { SvgAssetIconNames } from "../SvgAsset"; import "./index.scss"; import Condition, { If, IfTruthy } from "../Condition"; import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; export interface IContextMenuItem { /** 左侧图标 */ icon?: SvgAssetIconNames; /** 列表标题 */ title?: string; /** 是否是分割线 */ divider?: boolean; /** 是否展示 */ show?: boolean; /** 点击事件 */ onClick?: (value?: IContextMenuItem) => void; /** 子菜单 */ subMenu?: IContextMenuItem[]; } interface IContextMenuData { /** 菜单 */ menuItems?: IContextMenuItem[]; /** 出现位置 x */ x: number; /** 出现位置 y */ y: number; /** 设置子目录 */ setSubMenu?: ( subMenu?: Omit, menuItem?: IContextMenuItem ) => void; onItemClick?: (value: any) => void; /** 自定义的菜单 */ width?: number; height?: number; component?: ReactNode; } const contextMenuDataStore = new Store(null); export function showContextMenu( contextMenuData: Pick, ) { contextMenuDataStore.setValue(contextMenuData); } export function showCustomContextMenu( contextMenuData: Pick< IContextMenuData, "x" | "y" | "width" | "height" | "component" >, ) { contextMenuDataStore.setValue(contextMenuData); } function hideContextMenu() { contextMenuDataStore.setValue(null); } const menuItemWidth = 240; const menuItemHeight = 32; const menuContainerMaxHeight = menuItemHeight * 10; function SingleColumnContextMenuComponent(props: IContextMenuData) { const { menuItems, x, y, setSubMenu, onItemClick } = props; const menuContainerRef = useRef(); return (
{menuItems.map((item, index) => (
{ item.onClick?.(); onItemClick?.(item); }} onMouseEnter={(e) => { const subMenu = item.subMenu; if (!subMenu) { setSubMenu?.(null, item); return; } const realPos = y + (e.target as HTMLDivElement).offsetTop - menuContainerRef.current.scrollTop; const realHeight = Math.min( subMenu.length * menuItemHeight, menuContainerMaxHeight, ); let [subX, subY] = [ x - menuItemWidth - offset, realPos - realHeight / 2, ]; if (x < window.innerWidth - x - offset - menuItemWidth) { subX = x + menuItemWidth + offset; } if (subY < 54) { subY = 54; } if (subY + realHeight > window.innerHeight - 64 - offset) { subY = window.innerHeight - 64 - realHeight - offset; } setSubMenu?.( { menuItems: subMenu, x: subX, y: subY, }, item, ); }} style={{ height: menuItemHeight, }} >
{item.title}
))}
); } const offset = 6; export function ContextMenuComponent() { const contextMenuData = contextMenuDataStore.useValue(); const { menuItems, x, y, width, height, component } = contextMenuData ?? {}; const [subMenuData, setSubMenuData] = useState(null); const [actualX, actualY] = useMemo(() => { if (x === undefined || y === undefined) { return [-1000, -1000]; } const isLeft = x < window.innerWidth / 2 ? 0 : 1; const isTop = y < window.innerHeight / 2 ? 0 : 2; const containerHeight = Math.min( component ? height : menuItems.reduce( (prev, curr) => prev + (curr.show !== false ? (curr.divider ? 1 : menuItemHeight) : 0), menuItemHeight / 2, ), menuContainerMaxHeight, ); const containerWidth = width ?? menuItemWidth; switch (isLeft + isTop) { case 0: // 左上角 return [x + offset, y + offset]; case 1: // 右上角 return [x - containerWidth - offset, y + offset]; case 2: // 左下角 return [x + offset, y - offset - containerHeight]; case 3: // 右下角 return [x - containerWidth - offset, y - offset - containerHeight]; } }, [x, y]); useEffect(() => { const contextClickListener = () => { if (contextMenuDataStore.getValue()) { hideContextMenu(); } }; window.addEventListener("click", contextClickListener); return () => { window.removeEventListener("click", contextClickListener); }; }, []); useEffect(() => { setSubMenuData(null); }, [contextMenuData]); return ( { setSubMenuData( data ? { ...data, onItemClick(value) { menuItem?.onClick?.(value); }, } : data, ); }} >
{component}
); } ================================================ FILE: src/renderer/components/DragReceiver/index.scss ================================================ .components--drag-receiver { position: absolute; left: 0; height: 12px; width: 100%; display: flex; align-items: center; & .components--drag-receiver-content { width: 100%; height: 2px; background-color: var(--primaryColor); pointer-events: none; } } .components--drag-receiver-top { top: -6px; } .components--drag-receiver-bottom { bottom: -6px; } .components--drag-receiver-table-container { width: 0; max-width: 0; flex-basis: 0; flex-grow: 0; } ================================================ FILE: src/renderer/components/DragReceiver/index.tsx ================================================ import { useCallback, useState, DragEvent } from "react"; import { IfTruthy } from "../Condition"; import "./index.scss"; interface IDragReceiverProps { // 位置:顶部/底部 position: "top" | "bottom"; // 当前响应器的下标 rowIndex: number; // 释放事件 onDrop?: (from: number, to: number) => void; /** 用来匹配拖拽源的tag */ tag?: string; /** 是否需要td标签包裹 */ insideTable?: boolean; } export default function DragReceiver(props: IDragReceiverProps) { const { position, rowIndex, onDrop, tag, insideTable } = props; const [draggingOver, setDraggingOver] = useState(false); const onDragOver = useCallback(() => { setDraggingOver(true); }, []); const onDragLeave = useCallback(() => { setDraggingOver(false); }, []); const contentComponent = (
{ const itemIndex = +e.dataTransfer.getData("itemIndex"); const itemTag = e.dataTransfer.getData("itemTag"); setDraggingOver(false); const _itemTag = (itemTag === "null" || itemTag === "undefined") ? null : `${itemTag}`; const _tag = tag ? `${tag}` : null; if (_itemTag !== _tag) { // tag 不一致 忽略 return; } if (itemIndex >= 0) { onDrop?.(itemIndex, rowIndex); } }} >
); return insideTable ? ( {contentComponent} ) : ( contentComponent ); } export function startDrag( e: DragEvent, itemIndex: number | string, tag?: string, ) { e.dataTransfer.setData("itemIndex", `${itemIndex}`); e.dataTransfer.setData("itemTag", tag ?? null); } ================================================ FILE: src/renderer/components/Empty/index.scss ================================================ .components--empty-container { width: 100%; display: flex; align-items: center; justify-content: center; min-height: 300px; } ================================================ FILE: src/renderer/components/Empty/index.tsx ================================================ import { CSSProperties } from "react"; import "./index.scss"; import { useTranslation } from "react-i18next"; interface IEmptyProps { style?: CSSProperties; } export default function Empty(props: IEmptyProps) { const { style } = props; const { t } = useTranslation(); return (
{t("empty.hint_empty")}
); } ================================================ FILE: src/renderer/components/Header/index.scss ================================================ .header-container { width: 100vw; height: var(--appHeaderHeight, 54px); background-color: var(--primaryColor); display: flex; box-sizing: border-box; justify-content: space-between; align-items: center; padding-left: 16px; -webkit-app-region: drag; flex-shrink: 0; font-size: 1rem; position: relative; z-index: 2000; & .left-part { display: flex; align-items: center; & .logo { width: 142px; height: 1.2rem; color: var(--headerTextColor); display: flex; align-items: center; & svg { height: 1.1rem; width: auto; } } & .header-search { -webkit-app-region: none; background-color: var( --headerPlaceholderColor, rgba($color: #000, $alpha: 0.14) ); position: relative; min-width: 220px; border-radius: 8px; padding: 0.5rem 8px; display: flex; align-items: center; margin-left: 14px; & .header-search-input { flex: auto; font-size: 1rem; line-height: 1.2rem; outline: none; border: none; background-color: transparent !important; color: var(--headerTextColor); padding: 0; &::placeholder { opacity: 0.7; user-select: none; color: var(--headerTextColor); } } & .search-submit { $box-size: 1.3em; margin-left: 2px; width: #{$box-size}; height: #{$box-size}; color: var(--headerTextColor); opacity: 0.7; cursor: pointer; display: flex; align-items: center; justify-content: center; &:hover { opacity: 1; } & svg { width: #{$box-size}; height: #{$box-size}; } } } } & .right-part { height: 100%; color: var(--headerTextColor); display: flex; justify-content: flex-end; align-items: center; -webkit-app-region: none; padding-right: 16px; gap: 4px; & .header-divider { height: 16px; width: 1px; opacity: 0.2; background-color: var(--headerTextColor); } & .sparkles-icon:hover { animation: vibrate 0.3s linear infinite both; } & .header-button { display: flex; align-items: center; justify-content: center; width: 26px; height: 20px; opacity: 0.6; cursor: pointer; &:hover { opacity: 1; } & svg { width: 20px; height: 20px; } } } } @keyframes vibrate { 0% { transform: translate(0); } 20% { transform: translate(-1px, 1px); } 40% { transform: translate(-1px, -1px); } 60% { transform: translate(1px, 1px); } 80% { transform: translate(1px, -1px); } 100% { transform: translate(0); } } ================================================ FILE: src/renderer/components/Header/index.tsx ================================================ import SvgAsset from "../SvgAsset"; import "./index.scss"; import { showModal } from "../Modal"; import { useNavigate } from "react-router-dom"; import { useRef, useState } from "react"; import HeaderNavigator from "./widgets/Navigator"; import MusicDetail from "../MusicDetail"; import Condition from "../Condition"; import SearchHistory from "./widgets/SearchHistory"; import { addSearchHistory } from "@/renderer/utils/search-history"; import { useTranslation } from "react-i18next"; import useAppConfig from "@/hooks/useAppConfig"; import AppConfig from "@shared/app-config/renderer"; import { appUtil, appWindowUtil } from "@shared/utils/renderer"; import { musicDetailShownStore } from "@renderer/components/MusicDetail/store"; export default function AppHeader() { const navigate = useNavigate(); const inputRef = useRef(); const [showSearchHistory, setShowSearchHistory] = useState(false); const isHistoryFocusRef = useRef(false); const isMiniMode = useAppConfig("private.minimode"); const { t } = useTranslation(); if (!showSearchHistory) { isHistoryFocusRef.current = false; } function onSearchSubmit() { if (inputRef.current.value) { search(inputRef.current.value); } } function search(keyword: string) { navigate(`/main/search/${encodeURIComponent(keyword)}`); musicDetailShownStore.setValue(false); addSearchHistory(keyword); setShowSearchHistory(false); } return (
{ showModal("Sparkles"); }} >
{ navigate("/main/theme"); MusicDetail.hide(); }} >
{ navigate("/main/setting"); MusicDetail.hide(); }} >
{ appWindowUtil.setMinimodeWindow(!isMiniMode); if (!isMiniMode) { appWindowUtil.minMainWindow(true); } }} >
{ appWindowUtil.minMainWindow(); }} >
{ appWindowUtil.toggleMainWindowMaximize(); }}>
{ const exitBehavior = AppConfig.getConfig("normal.closeBehavior"); if (exitBehavior === "minimize") { appWindowUtil.minMainWindow(true); } else { appUtil.exitApp(); } }} >
); } ================================================ FILE: src/renderer/components/Header/widgets/Navigator/index.scss ================================================ .header-navigator { height: 40px; -webkit-app-region: none; display: flex; align-items: center; & .navigator-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 22px; cursor: pointer; color: var(--headerTextColor); border: 1px solid var(--dividerColor); &[data-disabled="true"] { color: var(--headerTextColor); opacity: 0.5; cursor: default; } & svg { width: 14px; height: 14px; } } } ================================================ FILE: src/renderer/components/Header/widgets/Navigator/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import "./index.scss"; import { useNavigate } from "react-router-dom"; import MusicDetail, { isMusicDetailShown } from "@/renderer/components/MusicDetail"; import { useTranslation } from "react-i18next"; export default function HeaderNavigator() { const navigate = useNavigate(); const canBack = history.state.idx > 0; const canGo = history.state.idx < history.length - 1; const { t } = useTranslation(); return (
{ if (isMusicDetailShown()) { MusicDetail.hide(); } else { navigate(-1); } }} >
{ if (isMusicDetailShown()) { MusicDetail.hide(); } else { navigate(1); } }} >
); } ================================================ FILE: src/renderer/components/Header/widgets/SearchHistory/index.scss ================================================ .search-history--container { position: absolute; box-sizing: border-box; width: 100%; height: 220px; overflow-y: auto; top: calc(100% + 4px); left: 0; border-radius: 4px; padding: 12px; & .search-history--header { height: 1.6rem; user-select: none; font-weight: 500; display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; & .search-history--header-clear { $size: 1rem; width: $size; height: $size; & svg { width: $size; height: $size; } } } & .search-history--body { display: flex; flex-wrap: wrap; gap: 6px 8px; } & .search-history--item[data-type="normalButton"] { font-size: 0.9rem; padding-right: 0.6rem; display: flex; align-items: center; justify-content: center; & .search-history--item-remove { width: 1rem; height: 1rem; margin-left: 6px; & svg { width: 1rem; height: 1rem; } } } } ================================================ FILE: src/renderer/components/Header/widgets/SearchHistory/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import "./index.scss"; import { useEffect, useState } from "react"; import { clearSearchHistory, getSearchHistory, removeSearchHistory, } from "@/renderer/utils/search-history"; import Condition from "@/renderer/components/Condition"; import Empty from "@/renderer/components/Empty"; import { useTranslation } from "react-i18next"; interface ISearchHistoryProps { onHistoryClick: (item: string) => void; onHistoryPanelFocus?: () => void; onHistoryPanelBlur?: () => void; } export default function SearchHistory(props: ISearchHistoryProps) { const { onHistoryClick, onHistoryPanelBlur, onHistoryPanelFocus } = props; const [historyList, removeHistory] = useSearchHistory(); const { t } = useTranslation(); return (
{t("app_header.search_history")}
{ removeHistory(); }} >
} > {historyList.map((historyItem) => (
{ onHistoryClick?.(historyItem); }} > {historyItem}
{ e.stopPropagation(); removeHistory(historyItem); }} >
))}
); } function useSearchHistory() { const [historyList, setHistoryList] = useState([]); function refreshHistoryList() { getSearchHistory().then((res) => { setHistoryList(res); }); } useEffect(() => { refreshHistoryList(); }, []); async function removeHistory(item?: string) { if (!item) { await clearSearchHistory(); } else { await removeSearchHistory(item); } refreshHistoryList(); } return [historyList, removeHistory] as const; } ================================================ FILE: src/renderer/components/Loading/index.scss ================================================ .loading-container { width: 100%; height: 100%; min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; & .spinner-container { display: inline-block; position: relative; width: 80px; height: 80px; } & .spinner-container div { display: inline-block; position: absolute; left: 8px; width: 16px; background: var(--primaryColor); animation: spinning-animation 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite; } & .spinner-container div:nth-child(1) { left: 8px; animation-delay: -0.24s; } & .spinner-container div:nth-child(2) { left: 32px; animation-delay: -0.12s; } & .spinner-container div:nth-child(3) { left: 56px; animation-delay: 0; } @keyframes spinning-animation { 0% { top: 8px; height: 64px; } 50%, 100% { top: 24px; height: 32px; } } & span { font-size: 1rem; user-select: none; &::after { content: ""; animation: text-animation 1.2s linear infinite; } } @keyframes text-animation { from { content: " "; } 25% { content: " ·"; } 50% { content: " ··"; } 75% { content: " ···"; } } } ================================================ FILE: src/renderer/components/Loading/index.tsx ================================================ import { useTranslation } from "react-i18next"; import "./index.scss"; interface ILoadingProps { text?: string } export default function Loading(props: ILoadingProps) { const { t } = useTranslation(); return (
{props.text ?? t("common.loading")}
); } ================================================ FILE: src/renderer/components/Modal/index.tsx ================================================ import Store from "@/common/store"; import templates from "./templates"; import { useMemo } from "react"; type ITemplate = typeof templates; type IModalType = keyof ITemplate; interface IModalInfo { type: IModalType | null; payload: any; } const modalStore = new Store({ type: null, payload: null, }); export default function ModalComponent() { const modalState = modalStore.useValue(); const component = useMemo(() => { if (modalState.type) { const Component = templates[modalState.type]; return ; } return null; }, [modalState]); return component; } export function showModal( type: T, payload?: Parameters[0], ) { modalStore.setValue({ type, payload, }); } export function hideModal() { modalStore.setValue({ type: null, payload: null, }); } ================================================ FILE: src/renderer/components/Modal/templates/AddMusicToSheet/index.scss ================================================ .modal--add-music-to-sheet-container { width: 400px; max-height: 540px; border-radius: 12px; display: flex; flex-direction: column; padding-bottom: 12px; & .components--modal-base-header { & .music-length { font-size: 1rem; font-weight: normal; } } & .music-sheets { flex: 1; overflow-y: auto; & .sheet-item { box-sizing: border-box; height: 64px; width: 100%; padding-left: 1rem; padding-right: 1rem; display: flex; align-items: center; font-size: 1.1rem; font-weight: 500; &:hover { background-color: var(--dividerColor); } & img { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; margin-right: 8px; } } } } ================================================ FILE: src/renderer/components/Modal/templates/AddMusicToSheet/index.tsx ================================================ import MusicSheet, { defaultSheet } from "@/renderer/core/music-sheet"; import Base from "../Base"; import "./index.scss"; import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import albumImg from "@/assets/imgs/album-cover.jpg"; import addImg from "@/assets/imgs/add.png"; import { hideModal, showModal } from "../.."; import { Trans, useTranslation } from "react-i18next"; interface IAddMusicToSheetProps { musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]; } export default function AddMusicToSheet(props: IAddMusicToSheetProps) { const { musicItems } = props; const { t } = useTranslation(); const allSheets = MusicSheet.frontend.useAllSheets(); return (
{t("modal.add_to_my_sheets")}{" "} ( )
{ showModal("AddNewSheet", { initMusicItems: musicItems, }); }} > {t("modal.create_local_sheet")}
{allSheets.map((sheet) => (
{ MusicSheet.frontend.addMusicToSheet(musicItems, sheet.id); hideModal(); }} > {sheet.id === defaultSheet.id ? t("media.default_favorite_sheet_name") : sheet.title}
))}
); } ================================================ FILE: src/renderer/components/Modal/templates/AddNewSheet/index.tsx ================================================ import { useCallback } from "react"; import MusicSheet from "@/renderer/core/music-sheet"; import debounce from "@/common/debounce"; import { hideModal } from "../.."; import SimpleInputWithState from "../SimpleInputWithState"; import { useTranslation } from "react-i18next"; import { CommonConst } from "@/common/constant"; interface IProps { initMusicItems: IMusic.IMusicItem | IMusic.IMusicItem[]; } export default function AddNewSheet(props: IProps) { const { t } = useTranslation(); const onCreateNewSheetClick = useCallback( debounce(async (newSheetName) => { try { const newSheet = await MusicSheet.frontend.addSheet(newSheetName); if (props?.initMusicItems) { await MusicSheet.frontend.addMusicToSheet(props.initMusicItems, newSheet.id); } hideModal(); } catch { console.log("创建失败"); } }, 500), [], ); return ( ); } ================================================ FILE: src/renderer/components/Modal/templates/Base/index.scss ================================================ .components--modal-base { position: fixed; z-index: 10010; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background-color: var(--maskColor); cursor: default !important; & .components--modal-base-header { width: 100%; display: flex; height: 3rem; box-sizing: border-box; padding-left: 1rem; padding-right: 1rem; align-items: center; justify-content: space-between; font-size: 1.2rem; font-weight: 600; user-select: none; /* background-color: rgba($color: #000000, $alpha: 0.1); */ border-bottom: 1px solid var(--dividerColor); flex-shrink: 0; & .components--modal-base-header-close { $size: 18px; width: $size; height: $size; & svg { width: $size; height: $size; } } } } ================================================ FILE: src/renderer/components/Modal/templates/Base/index.tsx ================================================ import { ReactNode, useRef } from "react"; import { hideModal } from "../.."; import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; interface IBaseModalProps { // 默认区域 onDefaultClick?: () => void; // 点击默认区域时关闭 defaultClose?: boolean; // 模糊 withBlur?: boolean; children: ReactNode; } const baseId = "components--modal-base-container"; function Base(props: IBaseModalProps) { const { onDefaultClick, defaultClose = false, children, withBlur = true, } = props; const trapCloseRef = useRef(false); return (
{ if ((e.target as HTMLElement)?.id === baseId) { trapCloseRef.current = true; } else { trapCloseRef.current = false; } }} onMouseUp={(e) => { if ((e.target as HTMLElement)?.id === baseId && trapCloseRef.current) { if (defaultClose) { hideModal(); } else { onDefaultClick?.(); } } }} onMouseLeave={() => { trapCloseRef.current = false; }} onMouseOut={() => { trapCloseRef.current = false; }} > {children}
); } interface IHeaderProps { children: ReactNode; } function Header(props: IHeaderProps) { const { children } = props; return (
{children}
{ hideModal(); }} >
); } Base.Header = Header; export default Base; ================================================ FILE: src/renderer/components/Modal/templates/ExitConfirm/index.scss ================================================ .modal--exit-confirm-container { width: 440px; height: 240px; border-radius: 8px; } ================================================ FILE: src/renderer/components/Modal/templates/ExitConfirm/index.tsx ================================================ import { useTranslation } from "react-i18next"; import Base from "../Base"; import "./index.scss"; export default function ExitConfirm() { const { t } = useTranslation(); return (
{t("modal.exit_confirm")}
); } ================================================ FILE: src/renderer/components/Modal/templates/ImportMusicSheet/index.scss ================================================ .modal--import-music-sheet { width: 360px; & .content-container { max-height: 540px; min-height: 100px; } & .plugin-item { box-sizing: border-box; width: 100%; height: 3rem; line-height: 3rem; font-size: 1rem; padding-left: 1rem; padding-right: 1rem; &:hover { background: var(--listHoverColor); } } } ================================================ FILE: src/renderer/components/Modal/templates/ImportMusicSheet/index.tsx ================================================ import { hideModal, showModal } from "../.."; import Base from "../Base"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; import "./index.scss"; import NoPlugin from "@renderer/components/NoPlugin"; import PluginManager from "@shared/plugin-manager/renderer"; interface IProps { plugins: IPlugin.IPluginDelegate[]; } export default function ImportMusicSheet(props: IProps) { const { plugins } = props; const { t } = useTranslation(); return (
{t("plugin.method_import_music_sheet")}
{ plugins?.length > 0 ? <>{plugins.map((it) => (
{ hideModal(); showModal("SimpleInputWithState", { title: t("plugin.method_import_music_sheet"), withLoading: true, loadingText: t("plugin_management_page.importing_media"), placeholder: t( "plugin_management_page.placeholder_import_music_sheet", { plugin: it.platform, }, ), maxLength: 1000, onOk(text) { return PluginManager.callPluginDelegateMethod( it, "importMusicSheet", text.trim(), ); }, onPromiseResolved(result) { hideModal(); showModal("AddMusicToSheet", { musicItems: result as IMusic.IMusicItem[], }); }, onPromiseRejected() { toast.error(t("plugin_management_page.import_failed")); }, hints: it.hints?.importMusicSheet, }); }} > {it.platform}
))} : }
); } ================================================ FILE: src/renderer/components/Modal/templates/PluginSubscription/index.scss ================================================ .modal--plugin-subscription { width: 420px; border-radius: 12px; display: flex; flex-direction: column; min-height: 164px; max-height: 560px; & .content-container { font-size: 1.1rem; flex: 1; overflow: auto; & .content-item { margin: 1rem; width: 360px; & .content-item-row { display: flex; align-items: center; &:last-child { margin-top: 0.5rem; } & span { width: 4rem; } & input { flex: 1; } } } } & .opeartion-area { flex-shrink: 0; display: flex; justify-content: center; align-items: center; column-gap: 16px; font-size: 1.1rem; margin-bottom: 1rem; } } ================================================ FILE: src/renderer/components/Modal/templates/PluginSubscription/index.tsx ================================================ import { getUserPreference, setUserPreference, } from "@/renderer/utils/user-perference"; import { hideModal } from "../.."; import Base from "../Base"; import "./index.scss"; import { ReactNode, useState } from "react"; import Condition from "@/renderer/components/Condition"; import Empty from "@/renderer/components/Empty"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; export default function PluginSubscription() { const [subscription, setSubscription] = useState( getUserPreference("subscription") ?? [], ); const { t } = useTranslation(); return (
{t("modal.plugin_subscription")}
}> {subscription.map((item, index) => (
{t("modal.subscription_remarks")} { setSubscription((prev) => { const newSub = [...prev]; newSub[index].title = e.target.value; return newSub; }); }} >
{t("modal.subscription_links")} { setSubscription((prev) => { const newSub = [...prev]; newSub[index].srcUrl = e.target.value; return newSub; }); }} >
))}
{ setSubscription((prev) => [ ...prev, { title: "", srcUrl: "", }, ]); }} > {t("common.add")}
{ setUserPreference( "subscription", subscription.filter((item) => item.srcUrl.match(/https?:\/\/.+\.js(on)?/), ), ); toast.success(t("modal.subscription_save_success")); hideModal(); }} > {t("common.save")}
); } ================================================ FILE: src/renderer/components/Modal/templates/Reconfirm/index.scss ================================================ .modal--reconfirm { width: 420px; border-radius: 12px; display: flex; flex-direction: column; min-height: 164px; max-height: 340px; & .content-container { font-size: 1.1rem; display: flex; align-items: center; justify-content: center; flex: 1; } & .opeartion-area { flex-shrink: 0; display: flex; justify-content: center; align-items: center; column-gap: 16px; font-size: 1.1rem; margin-bottom: 1rem; } } ================================================ FILE: src/renderer/components/Modal/templates/Reconfirm/index.tsx ================================================ import { useTranslation } from "react-i18next"; import { hideModal } from "../.."; import Base from "../Base"; import "./index.scss"; import { ReactNode } from "react"; interface IReconfirmProps { title: string; content: ReactNode; onConfirm?: () => void; onCancel?: () => void; } export default function Reconfirm(props: IReconfirmProps) { const { title, content, onConfirm, onCancel } = props; const { t } = useTranslation(); return (
{title}
{content}
{ onCancel?.(); hideModal(); }} > {t("common.cancel")}
{t("common.confirm")}
); } ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/hooks/searchResultStore.ts ================================================ import { RequestStateCode } from "@/common/constant"; import Store from "@/common/store"; export interface ISearchLyricResult { data: ILyric.ILyricItem[]; state: RequestStateCode; page: number; } interface ISearchLyricStoreData { query?: string; // plugin - result data: Record; } export default new Store({ data: {} }); ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/hooks/useSearchLyric.ts ================================================ import { RequestStateCode } from "@/common/constant"; import { useCallback, useRef } from "react"; import searchResultStore from "./searchResultStore"; import { produce } from "immer"; import { useTranslation } from "react-i18next"; import PluginManager from "@shared/plugin-manager/renderer"; export default function () { // 当前正在搜索 const currentQueryRef = useRef(""); const { t } = useTranslation(); /** * query: 搜索词 * queryPage: 搜索页码 * pluginHash: 搜索条件 */ const search = useCallback(async function ( query?: string, queryPage?: number, pluginHash?: string, ) { /** 如果没有指定插件,就用所有插件搜索 */ console.log("SEARCH LRC", query, queryPage); let plugins: IPlugin.IPluginDelegate[] = []; if (pluginHash) { const tgtPlugin = PluginManager.getPluginByHash(pluginHash); if (tgtPlugin) { plugins = [tgtPlugin]; } } else { plugins = PluginManager.getSearchablePlugins("lyric"); } if (plugins.length === 0) { searchResultStore.setValue( produce(draft => { draft.data = {}; }), ); return; } console.log(plugins); // 使用选中插件搜素 plugins.forEach(async plugin => { const _platform = plugin.platform; const _hash = plugin.hash; if (!_platform || !_hash) { // 插件无效,此时直接进入结果页 searchResultStore.setValue( produce(draft => { draft.data = {}; }), ); return; } // 上一份搜索结果 const prevPluginResult = searchResultStore.getValue().data[plugin.hash]; /** 上一份搜索还没返回/已经结束 */ if ( (prevPluginResult?.state & RequestStateCode.PENDING_FIRST_PAGE || prevPluginResult?.state === RequestStateCode.FINISHED) && undefined === query ) { return; } // 是否是一次新的搜索 const newSearch = query || prevPluginResult?.page === undefined || queryPage === 1; // 本次搜索关键词 currentQueryRef.current = query = query ?? searchResultStore.getValue().query ?? ""; /** 搜索的页码 */ const page = queryPage ?? newSearch ? 1 : (prevPluginResult?.page ?? 0) + 1; try { searchResultStore.setValue( produce(draft => { const prevMediaResult = draft.data; prevMediaResult[_hash] = { state: newSearch ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE, // @ts-ignore data: newSearch ? [] : prevMediaResult[_hash]?.data ?? [], page, }; }), ); const result = await PluginManager.callPluginDelegateMethod(plugin, "search", query, page, "lyric"); console.log(result); /** 如果搜索结果不是本次结果 */ if (currentQueryRef.current !== query) { return; } /** 切换到结果页 */ if (!result) { throw new Error(t("modal.serach_lyric_result_empty")); } searchResultStore.setValue( produce(draft => { const prevMediaResult = draft.data; const prevPluginResult: any = prevMediaResult[ _hash ] ?? { data: [], }; const currResult = result.data ?? []; prevMediaResult[_hash] = { state: result?.isEnd === false && result?.data?.length ? RequestStateCode.PARTLY_DONE : RequestStateCode.FINISHED, page, data: newSearch ? currResult : (prevPluginResult.data ?? []).concat( currResult, ), }; return draft; }), ); } catch (e: any) { /** 如果搜索结果不是本次结果 */ if (currentQueryRef.current !== query) { return; } searchResultStore.setValue( produce(draft => { const prevMediaResult = draft.data; const prevPluginResult = prevMediaResult[_hash] ?? { state: RequestStateCode.PARTLY_DONE, data: [] as ILyric.ILyricItem[], }; prevPluginResult.state = RequestStateCode.PARTLY_DONE; return draft; }), ); } }); }, []); return search; } ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/index.scss ================================================ .modal--search-lyric-container { width: 600px; max-height: 540px; border-radius: 8px; display: flex; flex-direction: column; & .search-lyric-input-container { margin-right: 24px; flex: 1; position: relative; & .search-lyric-input { width: 100%; padding-right: 26px; } & .search-lyric-search { position: absolute; top: 0; right: 0; padding: 0 6px; height: 100%; display: flex; align-items: center; justify-content: center; & svg { width: 1.1rem; height: 1.1rem; } } } & .tab-list-container { padding: 0 12px; overflow-x: auto; } & .tab-panels-container { & .tab-panel-container { height: 320px; } } } ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/index.tsx ================================================ import { useEffect, useState } from "react"; import Base from "../Base"; import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; import useSearchLyric from "./hooks/useSearchLyric"; import searchResultStore from "./hooks/searchResultStore"; import { Tab } from "@headlessui/react"; import SearchResult from "./searchResult"; import { useTranslation } from "react-i18next"; import PluginManager from "@shared/plugin-manager/renderer"; interface IProps { defaultTitle?: string; musicItem?: IMusic.IMusicItem; defaultExtra?: boolean; } export default function SearchLyric(props: IProps) { const { defaultTitle, musicItem } = props; const [inputSearch, setInputSearch] = useState(defaultTitle ?? ""); const searchLyric = useSearchLyric(); const searchResults = searchResultStore.useValue(); const { t } = useTranslation(); const availablePlugins = PluginManager.getSearchablePlugins("lyric"); useEffect(() => { if (inputSearch) { searchLyric(inputSearch); } }, []); return (
{ setInputSearch(evt.target.value); }} onKeyDown={(key) => { if (key.key === "Enter") { searchLyric(inputSearch); } }} >
{ searchLyric(inputSearch); }} >
{availablePlugins.map((plugin) => ( {plugin.platform} ))} {availablePlugins.map((plugin) => ( ))}
); } ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/searchResult.scss ================================================ .search-result-container { width: 100%; height: 100%; & .search-result-falsy-container { width: 100%; height: 100%; box-sizing: border-box; overflow-x: hidden; overflow-y: auto; & .lyric-item { width: 100%; height: 64px; display: flex; align-items: center; padding: 0 24px; &:hover { background-color: var(--listHoverColor); } &:active { background-color: var(--listActiveColor); } & img { width: 48px; height: 48px; border-radius: 8px; } & .lyric-info { flex: 1; margin-left: 16px; & .artist { margin-top: 8px; font-size: 0.9rem; opacity: 0.8; } } } } } ================================================ FILE: src/renderer/components/Modal/templates/SearchLyric/searchResult.tsx ================================================ import { memo } from "react"; import { ISearchLyricResult } from "./hooks/searchResultStore"; import { If } from "@/renderer/components/Condition"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; import albumImg from "@/assets/imgs/album-cover.jpg"; import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import Empty from "@/renderer/components/Empty"; import "./searchResult.scss"; import { linkLyric } from "@/renderer/core/link-lyric"; import { getMediaPrimaryKey } from "@/common/media-util"; import { toast } from "react-toastify"; import { hideModal } from "../.."; import { useTranslation } from "react-i18next"; import trackPlayer from "@renderer/core/track-player"; interface ISearchResultProps { data: ISearchLyricResult; musicItem?: IMusic.IMusicItem; } function SearchResult(props: ISearchResultProps) { const { data, musicItem } = props; const { t } = useTranslation(); return (
{ {(data?.data ?? []).map((it) => (
{ if (musicItem) { try { await linkLyric(musicItem, it); if (trackPlayer.isCurrentMusic(musicItem)) { trackPlayer.fetchCurrentLyric(true); } toast.success(t("modal.media_lyric_linked")); hideModal(); } catch (e) { toast.error(`${t("modal.media_lyric_link_failed")} ${e?.message ?? e}`); } } }} >
{it.title}
{it.artist}
))}
}
); } export default memo(SearchResult, (prev, curr) => prev.data === curr.data); ================================================ FILE: src/renderer/components/Modal/templates/SelectOne/index.scss ================================================ .modal--select-one-container { width: 400px; max-height: 540px; border-radius: 8px; display: flex; flex-direction: column; & .modal--body-container { flex: 1; padding-left: 16px; padding-right: 16px; overflow: auto; & .row-container { height: 2.6rem; display: flex; align-items: center; &[data-selected="true"] { color: var(--primaryColor); } } } & .footer-options { border-top: 1px solid var(--dividerColor); flex-shrink: 0; height: 3rem; display: flex; align-items: center; justify-content: end; gap: 12px; padding: 0.3rem 16px; & .footer-extra { display: flex; align-items: center; flex: 1; & .checkbox { width: 1rem; height: 1rem; border-radius: 2px; border: 1px solid currentColor; margin-right: 0.4rem; position: relative; & svg { position: absolute; width: 1rem; height: 1rem; left: 0; top: 0; } } } } } ================================================ FILE: src/renderer/components/Modal/templates/SelectOne/index.tsx ================================================ import { useState } from "react"; import { hideModal } from "../.."; import Base from "../Base"; import "./index.scss"; import Condition from "@/renderer/components/Condition"; import classNames from "@/renderer/utils/classnames"; import SvgAsset from "@/renderer/components/SvgAsset"; import { useTranslation } from "react-i18next"; interface IProps { title: string; choices: Array<{ label?: string; value: any; }>; extra?: string; // 附加字段 onOk?: (value: any, extra?: boolean) => void; defaultValue?: any; defaultExtra?: boolean; } export default function SelectOne(props: IProps) { const { title, choices, onOk, defaultValue, extra, defaultExtra } = props; const [selectedIndex, setSelectedIndex] = useState( defaultValue !== undefined ? choices.findIndex((choice) => choice.value === defaultValue) : -1, ); const [extraChecked, setExtraChecked] = useState(defaultExtra ?? false); const { t } = useTranslation(); return (
{title}
{choices.map((choice, index) => (
{ setSelectedIndex(index); }} > {choice.label ?? choice.value}
))}
{ setExtraChecked((prev) => !prev); }} >
{extra}
{ hideModal(); }} > {t("common.cancel")}
{ onOk?.(choices[selectedIndex]?.value, extraChecked); hideModal(); }} > {t("common.confirm")}
); } ================================================ FILE: src/renderer/components/Modal/templates/SimpleInputWithState/index.scss ================================================ .modal--simple-input-with-state { width: 420px; border-radius: 12px; display: flex; flex-direction: column; min-height: 164px; max-height: 340px; & .input-area { margin: 20px; margin-left: 1rem; box-sizing: border-box; & input { width: 100%; } } & .opeartion-area { display: flex; align-items: center; justify-content: center; font-size: 1.1rem; } & .hint-area { padding-left: 14px; padding-right: 14px; margin-bottom: 1rem; flex: 1; overflow-y: auto; & li { line-height: 2rem; white-space: normal; word-wrap: break-word; word-break: break-all; } } } ================================================ FILE: src/renderer/components/Modal/templates/SimpleInputWithState/index.tsx ================================================ import { ReactNode, useState } from "react"; import "./index.scss"; import Base from "../Base"; import useMounted from "@/hooks/useMounted"; import Condition from "@/renderer/components/Condition"; import Loading from "@/renderer/components/Loading"; import { useTranslation } from "react-i18next"; interface ISimpleInputWithStateProps { title: string; defaultValue?: string; placeholder?: string; hints?: ReactNode[]; maxLength?: number; withLoading?: boolean; // 是否需要中间状态 okText?: string; loadingText?: string; onOk?: (text: string) => any; onPromiseResolved?: (result: PromiseItem) => void; onPromiseRejected?: (reason?: any) => void; } export default function SimpleInputWithState( props: ISimpleInputWithStateProps, ) { const { title, defaultValue, placeholder, hints, maxLength, withLoading, okText, loadingText, onOk, onPromiseRejected, onPromiseResolved, } = props; const [loading, setLoading] = useState(false); const [inputText, setInputText] = useState(defaultValue ?? ""); const isMounted = useMounted(); const { t } = useTranslation(); return (
{title} } >
{ setInputText(e.target.value.slice(0, maxLength)); }} value={inputText} >
{ const result = onOk?.(inputText); if (withLoading) { setLoading(true); } result ?.then?.((res: any) => { if (isMounted.current) { onPromiseResolved?.(res); setLoading(false); } }) ?.catch((e: any) => { if (isMounted.current) { onPromiseRejected?.(e); setLoading(false); } }); }} > {okText ?? t("common.confirm")}
{hints?.map((hint, index) => (
  • {hint}
  • ))}
    ); } ================================================ FILE: src/renderer/components/Modal/templates/Sparkles/index.scss ================================================ .modal--sparkles-container { width: 600px; height: 400px; border-radius: 8px; display: flex; flex-direction: column; & p { line-height: 2rem; } & .img-container { width: 100%; display: flex; align-items: center; justify-content: center; & .wechat-channel { display: block; width: 120px; height: 120px; } } & .modal--body-container { padding-left: 16px; padding-right: 16px; flex: 1; overflow-y: auto; & .footer { width: 100%; text-align: end; } & .secret { font-size: 10px; color: transparent; user-select: none; } } } ================================================ FILE: src/renderer/components/Modal/templates/Sparkles/index.tsx ================================================ import A from "@/renderer/components/A"; import Base from "../Base"; import "./index.scss"; import wcChannelImg from "@/assets/imgs/wechat_channel.jpg"; export default function Sparkles() { return (
    ✨✨✨开发者的话

    首先感谢你使用这款软件。开发这款软件的初衷首先是满足自己日常的需求,顺便分享出来,如果能对更多人有帮助那再好不过。

    桌面版诞生于安卓版,在开发安卓版本的过程中逐渐发现有些地方的设计不合理,有些地方的代码也不太好,然后想到桌面版可以扩展出更多好玩的东西,所以趁着换工作的间隙,肝出了这个桌面版(的半成品)。安卓版本可以点击这里,后续如果有些更新可能会放在公众号上,也可以点个关注。(偶尔也会在公众号发一些技术文章,或者写个日记之类的,反正就随意吧)

    本软件完全免费,并基于GPL协议开源,仅供学习参考使用,不可用于商业目的。代码地址: Github{" "} Gitee

    本软件仅仅是一个本地播放器,也可以通过插件扩展第三方源,插件可以完成包括播放、搜索在内的大部分功能;如果你是从第三方下载的插件,请一定谨慎识别这些插件的安全性,保护好自己。(注意:插件以及插件可能产生的数据与本软件无关,请使用者合理合法使用。)

    还请注意本软件只是个人的业余项目,距离正式版也有很长一段距离。如果你在找成熟稳定的音乐软件,可以考虑其他优秀的软件。当然我会一直维护,让它变得尽可能的完善一些。业余时间用爱发电,进度慢还请见谅。如果你想帮忙提交代码或者开发一些功能,欢迎联系我(公众号留言/发邮件都行)。

    最后,如果真的有人看到这里,希望这款软件可以帮到你,这也是这款软件存在的意义。

    by: 猫头猫

    但愿有一天,我可以不受客观因素约束,把足够的时间投入到我所热爱的事情中(猫猫叹气
    ); } ================================================ FILE: src/renderer/components/Modal/templates/Update/index.scss ================================================ .modal--update-container { width: 600px; max-height: 400px; border-radius: 8px; display: flex; flex-direction: column; & .modal--body-container { flex: 1; padding-left: 16px; padding-right: 16px; overflow: auto; & .version { margin-top: 0.8rem; font-weight: 600; } & p { line-height: 1.5rem; } } & .footer-options { flex-shrink: 0; height: 3rem; display: flex; align-items: center; justify-content: end; gap: 12px; padding-right: 16px; margin-bottom: 0.5rem; } } ================================================ FILE: src/renderer/components/Modal/templates/Update/index.tsx ================================================ import { setUserPreference } from "@/renderer/utils/user-perference"; import Base from "../Base"; import "./index.scss"; import { hideModal } from "../.."; import { useTranslation } from "react-i18next"; import { shellUtil } from "@shared/utils/renderer"; interface IUpdateProps { currentVersion: string; update: ICommon.IUpdateInfo["update"]; } export default function Update(props: IUpdateProps) { const { currentVersion, update = {} as ICommon.IUpdateInfo["update"] } = props; const { t } = useTranslation(); return (
    {t("modal.new_version_found")}
    {t("modal.latest_version")} {update.version}
    {t("modal.current_version")} {currentVersion}
    {update.changeLog.map((item, index) => (

    {item}

    ))}
    { setUserPreference("skipVersion", update.version); hideModal(); }} > {t("modal.skip_this_version")}
    { shellUtil.openExternal(update.download[0]); }} > {t("common.update")}
    ); } ================================================ FILE: src/renderer/components/Modal/templates/WatchLocalDir/index.scss ================================================ .modal--watch-local-dir-container { width: 500px; border-radius: 8px; display: flex; flex-direction: column; & .modal--body-container { height: 280px; padding-left: 16px; padding-right: 16px; padding-top: 12px; padding-bottom: 12px; display: flex; flex-direction: column; & .modal--body-container-title { flex-shrink: 0; display: flex; align-items: center; width: 100%; justify-content: space-between; } & .modal--body-scan-content { width: 100%; margin-top: 12px; flex-grow: 1; border: 1px solid var(--dividerColor); overflow-y: auto; } & .row-container { height: 2.6rem; padding: 0 8px; display: flex; align-items: center; & .title { white-space: nowrap; margin: 0 6px; flex: 1; overflow: hidden; text-overflow: ellipsis; } & .delete-path { color: #fc5f5f; width: 20px; height: 20px; & svg { width: 20px; height: 20px; } } &:hover { background-color: var(--listHoverColor); } &:active { background-color: var(--listActiveColor); } } } & .footer-options { flex-shrink: 0; height: 3rem; display: flex; align-items: center; justify-content: end; gap: 12px; padding-right: 16px; margin-bottom: 0.5rem; } } ================================================ FILE: src/renderer/components/Modal/templates/WatchLocalDir/index.tsx ================================================ import { getUserPreferenceIDB, setUserPreferenceIDB, } from "@/renderer/utils/user-perference"; import Base from "../Base"; import "./index.scss"; import { hideModal } from "../.."; import { useEffect, useRef, useState } from "react"; import Condition from "@/renderer/components/Condition"; import Empty from "@/renderer/components/Empty"; import SvgAsset from "@/renderer/components/SvgAsset"; import Checkbox from "@/renderer/components/Checkbox"; import localMusic from "@/renderer/core/local-music"; import { useTranslation } from "react-i18next"; import { dialogUtil } from "@shared/utils/renderer"; export default function WatchLocalDir() { // 全部的文件夹 const [localDirs, setLocalDirs] = useState([]); // 选中的文件夹 const [checkedDirs, setCheckedDirs] = useState(new Set()); const changeLogRef = useRef(new Map()); // key: path; value: op const { t } = useTranslation(); useEffect(() => { (async () => { const allDirs = (await getUserPreferenceIDB("localWatchDir")) ?? []; const checked = (await getUserPreferenceIDB("localWatchDirChecked")) ?? []; const allDirsSet = new Set(allDirs); const validChecked = checked.filter((it) => allDirsSet.has(it)); setLocalDirs([...allDirsSet]); setCheckedDirs(new Set(validChecked)); })(); }, []); return (
    {t("modal.scan_local_music")}
    {t("modal.scan_local_music_hint")}
    { const result = await dialogUtil.showOpenDialog({ title: t("modal.scan_local_music"), properties: ["openDirectory", "createDirectory"], }); if (!result.canceled) { const selected = result.filePaths[0]; if (!localDirs.includes(selected)) { const changeLog = changeLogRef.current; setCheckedDirs((prev) => { return new Set([...prev, selected]); }); setLocalDirs((prev) => [...prev, selected]); changeLog.set(selected, "add"); } } }} > {t("modal.add_folder")}
    } > {localDirs.map((item) => { const isChecked = checkedDirs.has(item); return (
    { setCheckedDirs((prev) => { const changeLog = changeLogRef.current; const itemChangeLog = changeLog.get(item); const isChecked = prev.has(item); // 如果此次没有任何变动,说明是旧有的,此时需要删除监听 if (!itemChangeLog) { changeLog.set(item, isChecked ? "delete" : "add"); } else if ( (itemChangeLog === "add" && isChecked) || (itemChangeLog === "delete" && !isChecked) ) { changeLog.delete(item); } if (isChecked) { prev.delete(item); } else { prev.add(item); } return new Set(prev); }); }} >
    {item}
    { e.stopPropagation(); const changeLog = changeLogRef.current; const itemChangeLog = changeLog.get(item); // 如果此次没有任何变动,说明是旧有的,此时需要删除监听 if (!itemChangeLog) { changeLog.set(item, "delete"); } else if (itemChangeLog === "add") { // 此次新增,但是被删掉了 changeLog.delete(item); console.log("heredelete", changeLog); } setLocalDirs((prev) => prev.filter((it) => it !== item), ); setCheckedDirs((prev) => { prev.delete(item); return new Set(prev); }); }} >
    ); })}
    { setUserPreferenceIDB("localWatchDir", localDirs); setUserPreferenceIDB("localWatchDirChecked", [...checkedDirs]); localMusic.changeWatchPath(changeLogRef.current); hideModal(); }} > {t("common.confirm")}
    ); } ================================================ FILE: src/renderer/components/Modal/templates/index.ts ================================================ import AddMusicToSheet from "./AddMusicToSheet"; import AddNewSheet from "./AddNewSheet"; import Base from "./Base"; import ExitConfirm from "./ExitConfirm"; import ImportMusicSheet from "./ImportMusicSheet"; import PluginSubscription from "./PluginSubscription"; import Reconfirm from "./Reconfirm"; import SearchLyric from "./SearchLyric"; import SelectOne from "./SelectOne"; import SimpleInputWithState from "./SimpleInputWithState"; import Sparkles from "./Sparkles"; import Update from "./Update"; import WatchLocalDir from "./WatchLocalDir"; export default { Base, ExitConfirm, AddNewSheet, AddMusicToSheet, Sparkles, SimpleInputWithState, Reconfirm, Update, WatchLocalDir, SelectOne, PluginSubscription, SearchLyric, ImportMusicSheet, }; ================================================ FILE: src/renderer/components/MusicBar/index.scss ================================================ .music-bar-container { width: 100vw; height: var(--appMusicBarHeight, 64px); border-top: 1px solid var(--dividerColor); flex-shrink: 0; display: flex; align-items: center; user-select: none; position: relative; z-index: 4000; } ================================================ FILE: src/renderer/components/MusicBar/index.tsx ================================================ import Slider from "./widgets/Slider"; import MusicInfo from "./widgets/MusicInfo"; import Controller from "./widgets/Controller"; import Extra from "./widgets/Extra"; import "./index.scss"; export default function MusicBar() { return (
    ); } ================================================ FILE: src/renderer/components/MusicBar/widgets/Controller/index.scss ================================================ // 控制区域 .music-controller { margin-left: 10px; flex: 1; height: 100%; display: flex; align-items: center; justify-content: center; & .controller-btn { cursor: pointer; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-left: 8px; margin-right: 8px; } & .primary-btn { background-color: var(--primaryColor); color: white; } & .play-or-pause { width: 40px; height: 40px; & svg { width: 24px; height: 24px; } } & .skip { width: 34px; height: 34px; & svg { width: 28px; height: 28px; } } } ================================================ FILE: src/renderer/components/MusicBar/widgets/Controller/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import "./index.scss"; import trackPlayer from "@renderer/core/track-player"; import { useTranslation } from "react-i18next"; import { PlayerState } from "@/common/constant"; import { usePlayerState } from "@renderer/core/track-player/hooks"; export default function Controller() { const playerState = usePlayerState(); const { t } = useTranslation(); return (
    { trackPlayer.skipToPrev(); }}>
    { if(playerState === PlayerState.Playing) { trackPlayer.pause(); } else { trackPlayer.resume(); } }} >
    { trackPlayer.skipToNext(); }} >
    ); } ================================================ FILE: src/renderer/components/MusicBar/widgets/Extra/index.scss ================================================ // 其他区域 .music-extra { width: 280px; height: 100%; display: flex; align-items: center; justify-content: flex-end; margin-right: 12px; flex-shrink: 0; z-index: 300; & .extra-btn { cursor: pointer; color: var(--textColor); margin-left: 12px; height: 32px; position: relative; display: flex; align-items: center; & .volume-bubble-container { position: absolute; z-index: 10; width: 3rem; height: 9rem; bottom: 100%; left: 50%; transform: translateX(-50%); cursor: default; display: flex; flex-direction: column; align-items: center; justify-content: center; & .volume-slider-container { height: 6rem; & .rc-slider-handle-dragging { box-shadow: 0 0 5px 5px var(--primaryColor); } } & .volume-slider-tag { font-size: 0.9rem; margin-top: 6px; } } & svg { width: 22px; height: 22px; } } } ================================================ FILE: src/renderer/components/MusicBar/widgets/Extra/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import "./index.scss"; import SwitchCase from "@/renderer/components/SwitchCase"; import trackPlayer from "@renderer/core/track-player"; import { useRef, useState } from "react"; import Condition from "@/renderer/components/Condition"; import Slider from "rc-slider"; import { showModal } from "@/renderer/components/Modal"; import classNames from "@/renderer/utils/classnames"; import { getCurrentPanel, hidePanel, showPanel } from "@/renderer/components/Panel"; import { useTranslation } from "react-i18next"; import AppConfig from "@shared/app-config/renderer"; import { isCN } from "@/shared/i18n/renderer"; import useAppConfig from "@/hooks/useAppConfig"; import { RepeatMode } from "@/common/constant"; import { useQuality, useRepeatMode, useSpeed, useVolume } from "@renderer/core/track-player/hooks"; import { appWindowUtil } from "@shared/utils/renderer"; import { musicDetailShownStore } from "@renderer/components/MusicDetail/store"; export default function Extra() { const repeatMode = useRepeatMode(); const { t } = useTranslation(); return (
    { trackPlayer.toggleRepeatMode(); }} title={ repeatMode === RepeatMode.Loop ? t("media.music_repeat_mode_loop") : repeatMode === RepeatMode.Queue ? t("media.music_repeat_mode_queue") : t("media.music_repeat_mode_shuffle") } >
    { if (getCurrentPanel()?.type === "PlayList") { hidePanel(); } else { showPanel("PlayList", { coverHeader: musicDetailShownStore.getValue(), }); } }} >
    ); } function VolumeBtn() { const volume = useVolume(); const tmpVolumeRef = useRef(null); const [showVolumeBubble, setShowVolumeBubble] = useState(false); const { t } = useTranslation(); return (
    { setShowVolumeBubble(true); }} onMouseOut={() => { setShowVolumeBubble(false); }} onClick={(e) => { if (tmpVolumeRef.current === null) { tmpVolumeRef.current = 0; } tmpVolumeRef.current = tmpVolumeRef.current === volume ? volume === 0 ? 1 : 0 : tmpVolumeRef.current; trackPlayer.setVolume(tmpVolumeRef.current); tmpVolumeRef.current = volume; }} >
    { e.stopPropagation(); }} >
    { trackPlayer.setVolume(val as number); }} value={volume} styles={{ track: { background: "var(--primaryColor)", }, handle: { height: 12, width: 12, marginLeft: -4, borderColor: "var(--primaryColor)", }, rail: { background: "#d8d8d8", }, }} >
    {(volume * 100).toFixed(0)}%
    ); } function SpeedBtn() { const speed = useSpeed(); const [showSpeedBubble, setShowSpeedBubble] = useState(false); const tmpSpeedRef = useRef(null); const { t } = useTranslation(); return (
    { setShowSpeedBubble(true); }} onMouseOut={() => { setShowSpeedBubble(false); }} onClick={() => { if (tmpSpeedRef.current === null || tmpSpeedRef.current === speed) { tmpSpeedRef.current = 1; } trackPlayer.setSpeed(tmpSpeedRef.current); tmpSpeedRef.current = speed; }} >
    { e.stopPropagation(); }} >
    { trackPlayer.setSpeed(val as number); }} value={speed} trackStyle={{ background: "var(--primaryColor)", }} handleStyle={{ height: 12, width: 12, marginLeft: -4, borderColor: "var(--primaryColor)", }} railStyle={{ background: "#d8d8d8", }} >
    {speed.toFixed(2)}x
    ); } function QualityBtn() { const quality = useQuality(); const { t } = useTranslation(); return (
    { showModal("SelectOne", { title: t("music_bar.choose_music_quality"), defaultValue: quality, defaultExtra: true, extra: t("music_bar.only_set_for_current_music"), choices: [ { value: "low", label: t("media.music_quality_low"), }, { value: "standard", label: t("media.music_quality_standard"), }, { value: "high", label: t("media.music_quality_high"), }, { value: "super", label: t("media.music_quality_super"), }, ], onOk(value, extra) { trackPlayer.setQuality(value as IMusic.IQualityKey); if (!extra) { AppConfig.setConfig({ "playMusic.defaultQuality": value, }); } }, }); }} >
    ); } function LyricBtn() { const enableDesktopLyric = useAppConfig("lyric.enableDesktopLyric"); const { t } = useTranslation(); return (
    { appWindowUtil.setLyricWindow(!enableDesktopLyric); }} >
    ); } ================================================ FILE: src/renderer/components/MusicBar/widgets/MusicInfo/index.scss ================================================ $width: 290px; .music-info-outer-container { width: $width; height: 100%; position: relative; overflow: hidden; } .music-info-content-container { position: relative; height: 100%; width: 100%; display: flex; align-items: center; transition: transform 0.3s ease; &[data-detail-shown="true"] { transform: translateY(-100%); } } .music-info-operations-container { box-sizing: border-box; padding: 0 12px; display: flex; align-items: center; gap: 12px; font-size: 1rem; & div[role='button'] { width: 22px; height: 22px; } & .music-info-operation-divider { height: 40%; width: 1px; background: var(--dividerColor); } } // 左边的信息区域 .music-info-container { box-sizing: border-box; position: relative; display: flex; align-items: center; width: $width; height: 48px; /* border-right: 1px solid var(--dividerColor); */ padding-left: 10px; & .open-detail { width: 44px; height: 44px; color: rgba($color: white, $alpha: 0.5); position: absolute; left: 10px; top: 2px; transition: all 200ms linear; opacity: 0; display: flex; align-items: center; justify-content: center; background-color: rgba($color: #000000, $alpha: 0.5); border-radius: 4px; & svg { width: 28px; height: 28px; } &:hover { opacity: 1; backdrop-filter: blur(5px); } } & .music-cover { width: 44px; height: 44px; object-fit: cover; flex-shrink: 0; border-radius: 4px; } & .music-info { display: flex; flex-direction: column; justify-content: space-around; margin-left: 10px; width: 226px; height: 48px; padding: 8px 8px 8px 0; font-size: 1rem; & .music-title { display: flex; justify-content: space-between; align-items: center; font-size: 1.1rem; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } } & .music-artist { display: flex; align-items: center; & div { opacity: 0.8; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; margin-right: 0.5rem; } & .artist { flex: 1; } & .progress { flex-shrink: 0; } } } } ================================================ FILE: src/renderer/components/MusicBar/widgets/MusicInfo/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import "./index.scss"; import Tag from "@/renderer/components/Tag"; import { secondsToDuration } from "@/common/time-util"; import MusicFavorite from "@/renderer/components/MusicFavorite"; import MusicDetail, { useMusicDetailShown } from "@/renderer/components/MusicDetail"; import albumImg from "@/assets/imgs/album-cover.jpg"; import { useTranslation } from "react-i18next"; import { useCurrentMusic, useProgress } from "@renderer/core/track-player/hooks"; import { hidePanel, showPanel } from "@renderer/components/Panel"; import MusicDownloaded from "@renderer/components/MusicDownloaded"; import PluginManager from "@shared/plugin-manager/renderer"; export default function MusicInfo() { const musicItem = useCurrentMusic(); const musicDetailShown = useMusicDetailShown(); const { t } = useTranslation(); function toggleMusicDetail() { if (musicDetailShown) { MusicDetail.hide(); } else { MusicDetail.show(); hidePanel(); } } return (
    {!musicItem ? null : ( <>
    {musicItem.title} {musicItem.platform}
    {musicItem.artist}
    )}
    { showPanel("MusicComment", { musicItem: musicItem, coverHeader: true, }); }}>
    ); } function Progress() { const { currentTime, duration } = useProgress(); return (
    {isFinite(duration) ? `${secondsToDuration(currentTime)}/${secondsToDuration(duration)}` : null}
    ); } ================================================ FILE: src/renderer/components/MusicBar/widgets/Slider/index.scss ================================================ @use "sass:math"; .music-bar--slider-container { position: absolute; width: 100%; left: 0; $height: 12px; top: - math.div($height, 2); height: $height; display: flex; justify-content: center; align-items: center; --slider-height: 2px; &:hover, &:active { --slider-height: 6px; } & .bar { width: 100%; height: var(--slider-height); transition: height 80ms linear; background-color: #d8d8d8; } & .active-bar { position: absolute; width: 100%; left: -100%; height: var(--slider-height); background-color: var(--primaryColor); transform: translateX(0); transition: height linear 80ms; } } ================================================ FILE: src/renderer/components/MusicBar/widgets/Slider/index.tsx ================================================ import { useEffect, useRef, useState } from "react"; import "./index.scss"; import trackPlayer from "@renderer/core/track-player"; import { useProgress } from "@renderer/core/track-player/hooks"; export default function Slider() { const [seekPercent, _setSeekPercent] = useState(null); const seekPercentRef = useRef(null); const { currentTime, duration } = useProgress(); const isPressedRef = useRef(false); function setSeekPercent(value: number | null) { _setSeekPercent(value); seekPercentRef.current = value; } useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (isPressedRef.current) { setSeekPercent(Math.max(0, Math.min(1, e.clientX / window.innerWidth))); } }; const onMouseUp = (e: MouseEvent) => { if (isPressedRef.current) { isPressedRef.current = false; const realProgress = trackPlayer.progress; trackPlayer.seekTo(realProgress.duration * seekPercentRef.current); setSeekPercent(null); } }; window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); }; }, []); return (
    { if (isFinite(duration) && duration) { isPressedRef.current = true; } }} onClick={(e) => { if (isFinite(duration) && duration) { trackPlayer.seekTo((duration * e.clientX) / window.innerWidth); } }} >
    ); } ================================================ FILE: src/renderer/components/MusicDetail/index.scss ================================================ .music-detail--container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 3000; box-sizing: border-box; padding-bottom: var(--appMusicBarHeight); display: flex; flex-direction: column; -webkit-app-region: no-drag; & .music-detail-background { top: 0; left: 0; right: 0; bottom: 0; background-size: cover; background-repeat: no-repeat; position: absolute; filter: blur(50px); opacity: 0.5; mask-image: linear-gradient(to bottom, #fff, transparent); -webkit-mask-image: linear-gradient(to bottom, #fff, transparent); z-index: -1; transition: background-image ease 300ms; } & .hide-music-detail { position: fixed; right: 36px; top: 36px; $size: 28px; width: $size; height: $size; & svg { width: $size; height: $size; } } & .music-title { height: 4.8rem; line-height: 4.8rem; text-align: center; font-size: 2rem; font-weight: 500; margin-top: 1.5rem; width: 80vw; margin-left: 10vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } & .music-info { display: flex; width: 70vw; margin-left: 15vw; font-size: 1.2rem; justify-content: center; & span { opacity: 0.8; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; margin-right: 1rem; font-weight: 300; } } & .music-body { width: 100%; box-sizing: border-box; padding: 4rem 0; height: 0; flex: 1; display: flex; justify-content: center; align-items: center; column-gap: 54px; & .music-album-options { $size: 35vmin; width: $size; height: $size; object-fit: cover; -webkit-user-drag: none; & .music-album { width: $size; height: $size; border-radius: 8px; object-fit: cover; } & .music-options { margin-top: 18px; height: 64px; width: 100%; display: flex; gap: 12px; } } } } ================================================ FILE: src/renderer/components/MusicDetail/index.tsx ================================================ import AnimatedDiv from "../AnimatedDiv"; import "./index.scss"; import albumImg from "@/assets/imgs/album-cover.jpg"; import Tag from "../Tag"; import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import Header from "./widgets/Header"; import Lyric from "./widgets/Lyric"; import Condition from "../Condition"; import { useTranslation } from "react-i18next"; import { useCurrentMusic } from "@renderer/core/track-player/hooks"; import { useEffect } from "react"; import { musicDetailShownStore } from "@renderer/components/MusicDetail/store"; export const isMusicDetailShown = musicDetailShownStore.getValue; export const useMusicDetailShown = musicDetailShownStore.useValue; function MusicDetail() { const musicItem = useCurrentMusic(); const musicDetailShown = musicDetailShownStore.useValue(); const { t } = useTranslation(); useEffect(() => { const escHandler = (evt: KeyboardEvent) => { if (evt.code === "Escape") { evt.preventDefault(); musicDetailShownStore.setValue(false); } }; window.addEventListener("keydown", escHandler); return () => { window.removeEventListener("keydown", escHandler); }; }, []); return ( { // hack logic: https://github.com/electron/electron/issues/32341 // force reflow to refresh drag region setTimeout(() => { document.body.style.width = "0"; document.body.getBoundingClientRect(); document.body.style.width = ""; }, 200); }} >
    {musicItem?.title || t("media.unknown_title")}
    {musicItem?.artist} {" "} - {musicItem?.album} {musicItem?.platform ? {musicItem.platform} : null}
    ); } MusicDetail.show = () => { musicDetailShownStore.setValue(true); }; MusicDetail.hide = () => { musicDetailShownStore.setValue(false); }; export default MusicDetail; ================================================ FILE: src/renderer/components/MusicDetail/store.ts ================================================ import Store from "@/common/store"; export const musicDetailShownStore = new Store(false); ================================================ FILE: src/renderer/components/MusicDetail/widgets/Header/index.scss ================================================ .music-detail--header-container { width: 100%; height: var(--appHeaderHeight); flex-shrink: 0; -webkit-app-region: drag; & .hide-music-detail { left: 24px; $size: 32px; top: 16px; width: $size; height: $size; -webkit-app-region: no-drag; cursor: pointer; & svg { width: $size; height: $size; } } & .music-detail--header-right { width: 100%; height: 100%; box-sizing: border-box; display: flex; align-items: center; justify-content: flex-end; padding-right: 16px; gap: 4px; & .header-button { display: flex; align-items: center; justify-content: center; width: 26px; height: 20px; opacity: 0.6; cursor: pointer; -webkit-app-region: no-drag; &:hover { opacity: 1; } & svg { width: 20px; height: 20px; } } } } ================================================ FILE: src/renderer/components/MusicDetail/widgets/Header/index.tsx ================================================ import "./index.scss"; import { musicDetailShownStore } from "@renderer/components/MusicDetail/store"; import SvgAsset from "@renderer/components/SvgAsset"; import { useTranslation } from "react-i18next"; import { appUtil, appWindowUtil } from "@shared/utils/renderer"; import AppConfig from "@shared/app-config/renderer"; export default function Header() { const { t } = useTranslation(); return
    { musicDetailShownStore.setValue(false); }} >
    { appWindowUtil.minMainWindow(); }} >
    { appWindowUtil.toggleMainWindowMaximize(); }}>
    { const exitBehavior = AppConfig.getConfig("normal.closeBehavior"); if (exitBehavior === "minimize") { appWindowUtil.minMainWindow(true); } else { appUtil.exitApp(); } }} >
    ; } ================================================ FILE: src/renderer/components/MusicDetail/widgets/Lyric/index.scss ================================================ $width: 40vw; $height: 100%; .lyric-container-outer { position: relative; width: $width; height: $height; & .lyric-options-container { position: absolute; right: -14px; bottom: 0; display: flex; flex-direction: column; gap: 6px; transform: translateX(100%); & .lyric-option-item { &[data-active="true"] { color: var(--primaryColor); } width: 18px; height: 18px; & svg { width: 100%; height: 100%; } } } } .lyric-container { width: $width; height: $height; overflow-y: auto; font-size: 1rem; cursor: default; position: relative; &::-webkit-scrollbar { display: none; } &::-webkit-scrollbar-track { background-color: transparent; } &::-webkit-scrollbar-thumb { background-color: #999; border-radius: 8px; } &:hover::-webkit-scrollbar { display: block; } &[data-loading="false"] { &::before, &::after { content: ""; height: calc(50% - 0.5rem); display: block; } } & .lyric-item { vertical-align: middle; text-align: center; padding-top: 0.6em; padding-bottom: 0.6em; font-size: 1em; opacity: 0.8; &[data-highlight="true"] { color: var(--primaryColor); font-size: 1.2em; font-weight: 600; opacity: 1; } } & .lyric-item-translation { margin-top: -0.6em; } & .search-lyric { color: var(--linkColor); text-decoration: underline; } } .lyric-ctx-menu--font-container { height: 48px; width: 100%; display: flex; align-items: center; & .font-size-button { width: 36px; height: 100%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; flex-grow: 0; & svg { width: 1.4rem; height: 1.4rem; } } & input { flex: 1; width: 0; text-align: center; margin: 0 12px; &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; } } } .lyric-ctx-menu--row-container { height: 36px; width: 100%; display: flex; align-items: center; padding-left: 12px; padding-right: 12px; box-sizing: border-box; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } &:hover { background-color: var(--listHoverColor); } } .lyric-ctx-menu--set-font-title { margin-top: 12px; font-weight: 600; opacity: 0.6; font-size: 1rem; height: 14px; padding-left: 12px; } ================================================ FILE: src/renderer/components/MusicDetail/widgets/Lyric/index.tsx ================================================ import "./index.scss"; import Condition, { IfTruthy } from "@/renderer/components/Condition"; import Loading from "@/renderer/components/Loading"; import { useEffect, useRef, useState } from "react"; import { showCustomContextMenu } from "@/renderer/components/ContextMenu"; import { getUserPreference, setUserPreference, useUserPreference, } from "@/renderer/utils/user-perference"; import { toast } from "react-toastify"; import { showModal } from "@/renderer/components/Modal"; import SvgAsset from "@/renderer/components/SvgAsset"; import LyricParser from "@/renderer/utils/lyric-parser"; import { getLinkedLyric, unlinkLyric } from "@/renderer/core/link-lyric"; import { getMediaPrimaryKey } from "@/common/media-util"; import { useTranslation } from "react-i18next"; import { useLyric } from "@renderer/core/track-player/hooks"; import trackPlayer from "@renderer/core/track-player"; import { dialogUtil, fsUtil } from "@shared/utils/renderer"; export default function Lyric() { const lyricContext = useLyric(); const lyricParser = lyricContext?.parser; const currentLrc = lyricContext?.currentLrc; const containerRef = useRef(); const [fontSize, setFontSize] = useState( getUserPreference("inlineLyricFontSize"), ); const [showTranslation, setShowTranslation] = useUserPreference("showTranslation"); const { t } = useTranslation(); const mountRef = useRef(false); useEffect(() => { if (containerRef.current) { const currentIndex = lyricContext?.currentLrc?.index; if (currentIndex >= 0) { const dom = document.querySelector(`#lyric-item-id-${currentIndex}`) as | HTMLDivElement | undefined; if (dom) { const offsetTop = dom.offsetTop - containerRef.current.clientHeight / 2 + dom.clientHeight / 2; containerRef.current.scrollTo({ behavior: mountRef.current ? "smooth" : "auto", top: offsetTop, }); } } } mountRef.current = true; }, [currentLrc]); const optionsComponent = (
    { setShowTranslation(!showTranslation); }} >
    ); return (
    { showCustomContextMenu({ x: e.clientX, y: e.clientY, width: 200, height: 146, component: ( ), }); }} style={ fontSize ? { fontSize: `${fontSize}px`, } : null } ref={containerRef} > { } >
    {t("music_detail.no_lyric")}
    { const currentMusic = trackPlayer.currentMusic; showModal("SearchLyric", { defaultTitle: currentMusic?.title, musicItem: currentMusic, }); }} > {t("music_detail.search_lyric")}
    } > {lyricParser?.getLyricItems?.()?.map((lyricItem, index) => ( <>
    {lyricItem.lrc}
    {lyricItem.translation}
    ))}
    }
    {optionsComponent}
    ); } interface ILyricContextMenuProps { setLyricFontSize: (val: string) => void; lyricParser: LyricParser; } function LyricContextMenu(props: ILyricContextMenuProps) { const { setLyricFontSize, lyricParser } = props; const [fontSize, setFontSize] = useState( getUserPreference("inlineLyricFontSize") ?? "13", ); const [showTranslation, setShowTranslation] = useUserPreference("showTranslation"); const [linkedLyricInfo, setLinkedLyricInfo] = useState(null); const { t } = useTranslation(); const currentMusicRef = useRef( trackPlayer.currentMusic ?? ({} as any), ); useEffect(() => { if (currentMusicRef.current?.platform) { getLinkedLyric(currentMusicRef.current).then((linked) => { if (linked) { setLinkedLyricInfo(linked); } }); } }, []); function handleFontSize(val: string | number) { if (val) { const nVal = +val; if (8 <= nVal && nVal <= 32) { setUserPreference("inlineLyricFontSize", `${val}`); setLyricFontSize(`${val}`); } } } async function downloadLyric(fileType: "lrc" | "txt") { let rawLrc = ""; if (fileType === "lrc") { rawLrc = lyricParser.toString({ withTimestamp: true, }); } else { rawLrc = lyricParser.toString(); } try { const result = await dialogUtil.showSaveDialog({ title: t("music_detail.lyric_ctx_download_lyric"), defaultPath: currentMusicRef.current.title + (fileType === "lrc" ? ".lrc" : ".txt"), filters: [ { name: t("media.media_type_lyric"), extensions: ["lrc", "txt"], }, ], }); if (!result.canceled && result.filePath) { await fsUtil.writeFile(result.filePath, rawLrc, "utf-8"); toast.success(t("music_detail.lyric_ctx_download_success")); } else { throw new Error(); } } catch { toast.error(t("music_detail.lyric_ctx_download_fail")); } } return ( <>
    {t("music_detail.lyric_ctx_set_font_size")}
    e.stopPropagation()} >
    { if (fontSize) { setFontSize((prev) => { const newFontSize = +prev - 1; handleFontSize(newFontSize); if (newFontSize < 8) { return "8"; } else if (newFontSize > 32) { return "32"; } return `${newFontSize}`; }); } }} >
    { const val = e.target.value; handleFontSize(val); setFontSize(e.target.value.trim()); }} >
    { if (fontSize) { setFontSize((prev) => { const newFontSize = +prev + 1; handleFontSize(newFontSize); if (newFontSize < 8) { return "8"; } else if (newFontSize > 32) { return "32"; } return `${newFontSize}`; }); } }} >
    { setShowTranslation(!showTranslation); }} > {showTranslation ? t("music_detail.hide_translation") : t("music_detail.show_translation")}
    { downloadLyric("lrc"); }} > {t("music_detail.lyric_ctx_download_lyric_lrc")}
    { downloadLyric("txt"); }} > {t("music_detail.lyric_ctx_download_lyric_txt")}
    { showModal("SearchLyric", { defaultTitle: currentMusicRef.current.title, musicItem: currentMusicRef.current, }); }} > {linkedLyricInfo ? `${t("music_detail.media_lyric_linked")} ${getMediaPrimaryKey( linkedLyricInfo, )}` : t("music_detail.search_lyric")}
    { try { await unlinkLyric(currentMusicRef.current); if (trackPlayer.isCurrentMusic(currentMusicRef.current)) { trackPlayer.fetchCurrentLyric(true); } toast.success(t("music_detail.toast_media_lyric_unlinked")); } catch { // pass } }} > {t("music_detail.unlink_media_lyric")}
    ); } ================================================ FILE: src/renderer/components/MusicDownloaded/index.scss ================================================ .music-download-base { display: flex; align-items: center; justify-content: center; } .music-downloaded { color: var(--infoColor, #0a95c8); } .music-can-download { opacity: 0.6; cursor: pointer; &:hover { opacity: 1; } } ================================================ FILE: src/renderer/components/MusicDownloaded/index.tsx ================================================ import { isSameMedia } from "@/common/media-util"; import SvgAsset, { SvgAssetIconNames } from "@/renderer/components/SvgAsset"; import { memo, useEffect, useState } from "react"; import "./index.scss"; import { DownloadState, localPluginName } from "@/common/constant"; import Downloader from "@/renderer/core/downloader"; import { useTranslation } from "react-i18next"; interface IMusicDownloadedProps { musicItem: IMusic.IMusicItem; size?: number; } function MusicDownloaded(props: IMusicDownloadedProps) { const { musicItem, size = 18 } = props; // const [loading, setLoading] = useState(false); const downloadState = Downloader.useDownloadState(musicItem); const { t } = useTranslation(); const isDownloadedOrLocal = downloadState === DownloadState.DONE || musicItem?.platform === localPluginName; let iconName: SvgAssetIconNames = "array-download-tray"; if (isDownloadedOrLocal) { iconName = "check-circle"; } else if ( downloadState !== DownloadState.NONE && downloadState !== DownloadState.ERROR ) { iconName = "rolling-1s"; } return (
    { if ( musicItem && (downloadState === DownloadState.NONE || downloadState === DownloadState.ERROR) ) { Downloader.startDownload(musicItem); } }} >
    ); } export default memo(MusicDownloaded, (prev, curr) => isSameMedia(prev.musicItem, curr.musicItem), ); ================================================ FILE: src/renderer/components/MusicFavorite/index.tsx ================================================ import SvgAsset from "../SvgAsset"; import MusicSheet from "@/renderer/core/music-sheet"; interface IMusicFavoriteProps { musicItem: IMusic.IMusicItem; size: number; } export default function MusicFavorite(props: IMusicFavoriteProps) { const { musicItem, size } = props; const isFav = MusicSheet.frontend.useMusicIsFavorite(musicItem); return (
    { e.stopPropagation(); if (isFav) { MusicSheet.frontend.removeMusicFromFavorite(musicItem); } else { MusicSheet.frontend.addMusicToFavorite(musicItem); } }} onDoubleClick={(e) => { e.stopPropagation(); }} style={{ color: isFav ? "red" : "var(--textColor)", width: size, height: size, }} >
    ); } ================================================ FILE: src/renderer/components/MusicList/index.scss ================================================ .music-list-container { width: 100%; min-height: 300px; &:focus-visible { outline: none; } & th { position: relative; &:not([data-id="like"]):hover { background-color: var(--listHoverColor); & .sort-container[data-sorting="false"] { opacity: 0.6; } } &:nth-child(2) { text-align: center; } & .sort-container { position: absolute; right: 2px; top: 50%; transform: translateY(-50%); width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; opacity: 0.8; & svg { width: 0.6rem; height: 0.6rem; } &[data-sorting="false"] { opacity: 0; } } } & tr { position: relative; } & td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; &:nth-child(2) { text-align: center; } } & .music-list-operations { display: flex; align-items: center; gap: 6px; } // & .resizer { // position: absolute; // right: 0; // top: 0; // height: 100%; // width: 4px; // cursor: col-resize; // user-select: none; // touch-action: none; // opacity: 0; // &:hover { // opacity: 1; // } // } // & .resizer-resizing { // opacity: 1; // } } .music-list-drag-receiver { position: absolute; left: 0; height: 12px; width: 100%; display: flex; align-items: center; & .music-list-drag-receiver-content { width: 100%; height: 2px; background-color: var(--primaryColor); pointer-events: none; } } .music-list-drag-receiver-top { top: -6px; } .music-list-drag-receiver-bottom { bottom: -6px; } ================================================ FILE: src/renderer/components/MusicList/index.tsx ================================================ import { ColumnDef, createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable, } from "@tanstack/react-table"; import "./index.scss"; import Tag from "../Tag"; import { secondsToDuration } from "@/common/time-util"; import MusicSheet from "@/renderer/core/music-sheet"; import trackPlayer from "@renderer/core/track-player"; import Condition, { IfTruthy } from "../Condition"; import Empty from "../Empty"; import MusicFavorite from "../MusicFavorite"; import MusicDownloaded from "../MusicDownloaded"; import { localPluginName, RequestStateCode } from "@/common/constant"; import BottomLoadingState from "../BottomLoadingState"; import { IContextMenuItem, showContextMenu } from "../ContextMenu"; import { getInternalData, getMediaPrimaryKey, isSameMedia } from "@/common/media-util"; import { CSSProperties, memo, useCallback, useEffect, useRef, useState } from "react"; import { showModal } from "../Modal"; import useVirtualList from "@/hooks/useVirtualList"; import hotkeys from "hotkeys-js"; import Downloader from "@/renderer/core/downloader"; import { toast } from "react-toastify"; import SwitchCase from "../SwitchCase"; import SvgAsset from "../SvgAsset"; import musicSheetDB from "@/renderer/core/db/music-sheet-db"; import DragReceiver, { startDrag } from "../DragReceiver"; import { i18n } from "@/shared/i18n/renderer"; import isLocalMusic from "@/renderer/utils/is-local-music"; import AppConfig from "@shared/app-config/renderer"; import { shellUtil } from "@shared/utils/renderer"; interface IMusicListProps { /** 展示的播放列表 */ musicList: IMusic.IMusicItem[]; /** 实际的播放列表 */ getAllMusicItems?: () => IMusic.IMusicItem[]; /** 音乐列表所属的歌单信息 */ musicSheet?: IMusic.IMusicSheetItem; // enablePagination?: boolean; // 分页/虚拟长列表 state?: RequestStateCode; // 网络状态 doubleClickBehavior?: "replace" | "normal"; // 双击行为 onPageChange?: (page?: number) => void; // 分页 /** 虚拟滚动参数 */ virtualProps?: { offsetHeight?: number | (() => number); // 距离顶部的高度 getScrollElement?: () => HTMLElement; // 滚动 fallbackRenderCount?: number; }; containerStyle?: CSSProperties; hideRows?: Array< "like" | "index" | "title" | "artist" | "album" | "duration" | "platform" >; /** 允许拖拽 */ enableDrag?: boolean; /** 拖拽结束 */ onDragEnd?: (newMusicList: IMusic.IMusicItem[]) => void; /** context */ contextMenu?: IContextMenuItem[]; } const columnHelper = createColumnHelper(); const columnDef: ColumnDef[] = [ columnHelper.display({ id: "like", size: 42, minSize: 42, maxSize: 42, cell: (info) => (
    ), enableResizing: false, enableSorting: false, }), columnHelper.accessor((_, index) => index + 1, { cell: (info) => info.getValue(), header: "#", id: "index", minSize: 40, maxSize: 40, size: 40, enableResizing: false, }), columnHelper.accessor("title", { header: () => i18n.t("media.media_title"), size: 250, maxSize: 300, minSize: 100, cell: (info) => { const title = info?.getValue?.(); return {title}; }, // @ts-ignore fr: 3, }), columnHelper.accessor("artist", { header: () => i18n.t("media.media_type_artist"), size: 130, maxSize: 200, minSize: 60, cell: (info) => {info.getValue()}, // @ts-ignore fr: 2, }), columnHelper.accessor("album", { header: () => i18n.t("media.media_type_album"), size: 120, maxSize: 200, minSize: 60, cell: (info) => {info.getValue()}, // @ts-ignore fr: 2, }), columnHelper.accessor("duration", { header: () => i18n.t("media.media_duration"), size: 64, maxSize: 150, minSize: 48, cell: (info) => info.getValue() ? secondsToDuration(info.getValue()) : "--:--", // @ts-ignore fr: 1, }), columnHelper.accessor("platform", { header: () => i18n.t("media.media_platform"), size: 100, minSize: 80, maxSize: 300, cell: (info) => {info.getValue()}, // @ts-ignore fr: 1, }), ]; const estimizeItemHeight = 2.6 * 13; // lineheight 2.6rem export function showMusicContextMenu( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], x: number, y: number, sheetType?: string, ) { const menuItems: IContextMenuItem[] = []; const isArray = Array.isArray(musicItems); if (!isArray) { menuItems.push( { title: `ID: ${getMediaPrimaryKey(musicItems)}`, icon: "identification", }, { title: `${i18n.t("media.media_type_artist")}: ${ musicItems.artist ?? i18n.t("media.unknown_artist") }`, icon: "user", }, { title: `${i18n.t("media.media_type_album")}: ${ musicItems.album ?? i18n.t("media.unknown_album") }`, icon: "album", show: !!musicItems.album, }, { divider: true, }, ); } menuItems.push( { title: i18n.t("music_list_context_menu.next_play"), icon: "motion-play", onClick() { trackPlayer.addNext(musicItems); }, }, { title: i18n.t("music_list_context_menu.add_to_my_sheets"), icon: "document-plus", onClick() { showModal("AddMusicToSheet", { musicItems: musicItems, }); }, }, { title: i18n.t("music_list_context_menu.remove_from_sheet"), icon: "trash", show: !!sheetType && sheetType !== "play-list", onClick() { MusicSheet.frontend.removeMusicFromSheet(musicItems, sheetType); }, }, { title: i18n.t("common.remove"), icon: "trash", show: sheetType === "play-list", onClick() { trackPlayer.removeMusic(musicItems); }, }, ); menuItems.push( { title: i18n.t("common.download"), icon: "array-download-tray", show: isArray ? !musicItems.every( (item) => isLocalMusic(item) || Downloader.isDownloaded(item), ) : !isLocalMusic(musicItems) && !Downloader.isDownloaded(musicItems), onClick() { Downloader.startDownload(musicItems); }, }, { title: i18n.t("music_list_context_menu.delete_local_download"), icon: "trash", show: (isArray && musicItems.every((it) => Downloader.isDownloaded(it))) || (!isArray && Downloader.isDownloaded(musicItems)), async onClick() { const [isSuccess, info] = await Downloader.removeDownloadedMusic( musicItems, true, ); if (isSuccess) { if (isArray) { toast.success( i18n.t( "music_list_context_menu.delete_local_downloaded_songs_success", { musicNums: musicItems.length, }, ), ); } else { toast.success( i18n.t( "music_list_context_menu.delete_local_downloaded_song_success", { songName: (musicItems as IMusic.IMusicItem).title, }, ), ); } } else if (info?.msg) { toast.error(info.msg); } }, }, { title: i18n.t( "music_list_context_menu.reveal_local_music_in_file_explorer", ), icon: "folder-open", show: !isArray && (Downloader.isDownloaded(musicItems) || musicItems?.platform === localPluginName), async onClick() { try { if (!isArray) { let realTimeMusicItem = musicItems; if (musicItems.platform !== localPluginName) { realTimeMusicItem = await musicSheetDB.musicStore.get([ musicItems.platform, musicItems.id, ]); } const downloadPath = getInternalData( realTimeMusicItem, "downloadData", )?.path; const result = await shellUtil.showItemInFolder(downloadPath); if (!result) { throw new Error(); } } } catch (e) { toast.error( `${i18n.t( "music_list_context_menu.reveal_local_music_in_file_explorer_fail", )} ${e?.message ?? ""}`, ); } }, }, ); showContextMenu({ x, y, menuItems, }); } function _MusicList(props: IMusicListProps) { const { musicList, state = RequestStateCode.FINISHED, onPageChange, musicSheet, virtualProps, // getAllMusicItems, doubleClickBehavior, containerStyle, hideRows, enableDrag, onDragEnd, } = props; const [sorting, setSorting] = useState([]); const musicListRef = useRef(musicList); const columnShownRef = useRef( AppConfig.getConfig("normal.musicListColumnsShown").reduce( (prev, curr) => ({ ...prev, [curr]: false, }), {}, ), ); const table = useReactTable({ debugAll: false, data: musicList, columns: columnDef, state: { sorting: sorting, columnVisibility: hideRows ? hideRows.reduce((prev, curr) => ({ ...prev, [curr]: false }), { ...columnShownRef.current, }) : columnShownRef.current, }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), }); const tableContainerRef = useRef(); const virtualController = useVirtualList({ data: table.getRowModel().rows, getScrollElement: virtualProps?.getScrollElement, offsetHeight: () => tableContainerRef.current?.offsetTop ?? 0, estimateItemHeight: estimizeItemHeight, fallbackRenderCount: !( virtualProps?.getScrollElement ) ? -1 : virtualProps?.fallbackRenderCount ?? 50, }); const [activeItems, setActiveItems] = useState>(new Set()); const lastActiveIndexRef = useRef(0); useEffect(() => { setActiveItems(new Set()); lastActiveIndexRef.current = 0; musicListRef.current = musicList; }, [musicList]); useEffect(() => { const ctrlAHandler = (evt: Event) => { evt.preventDefault(); setActiveItems(new Set(Array.from({ length: musicListRef.current.length }, (_, i) => i))); }; hotkeys("Ctrl+A", "music-list", ctrlAHandler); return () => { hotkeys.unbind("Ctrl+A", ctrlAHandler); }; }, []); const _onDrop = useCallback( (fromIndex: number, toIndex: number) => { if (!onDragEnd || fromIndex === toIndex) { // 没有移动 return; } const newData = musicList .slice(0, fromIndex) .concat(musicList.slice(fromIndex + 1)); newData.splice( fromIndex > toIndex ? toIndex : toIndex - 1, 0, musicList[fromIndex], ); onDragEnd?.(newData); }, [onDragEnd, musicList], ); return (
    { hotkeys.setScope("music-list"); }} onBlur={() => { hotkeys.setScope("all"); }} > {table.getHeaderGroups()[0].headers.map((header) => ( ))} {virtualController.virtualItems.map((virtualItem, index) => { const row = virtualItem.dataItem; if (!row.original) { return null; } // todo 拆出一个组件 return ( { if ( activeItems.size > 1 ) { const selectedItems: IMusic.IMusicItem[] = []; const rows = table.getRowModel().rows; activeItems.forEach(item => { selectedItems.push(rows[item].original); }); showMusicContextMenu( selectedItems, e.clientX, e.clientY, musicSheet?.platform === localPluginName ? musicSheet.id : undefined, ); } else { lastActiveIndexRef.current = virtualItem.rowIndex; setActiveItems(new Set([virtualItem.rowIndex])); showMusicContextMenu( row.original, e.clientX, e.clientY, musicSheet?.platform === localPluginName ? musicSheet.id : undefined, ); } }} onClick={() => { // 如果点击的时候按下shift if (hotkeys.shift) { let start = lastActiveIndexRef.current; let end = virtualItem.rowIndex; if (start >= end) { [start, end] = [end, start]; } if (end > musicListRef.current.length) { end = musicListRef.current.length - 1; } setActiveItems( new Set( Array.from({ length: end - start + 1 }, (_, i) => start + i), ), ); } else if (hotkeys.ctrl) { const newSet = new Set(activeItems); if (newSet.has(virtualItem.rowIndex)) { newSet.delete(virtualItem.rowIndex); } else { newSet.add(virtualItem.rowIndex); } setActiveItems(newSet); } else { setActiveItems(new Set([virtualItem.rowIndex])); lastActiveIndexRef.current = virtualItem.rowIndex; } }} onDoubleClick={() => { const config = doubleClickBehavior ?? AppConfig.getConfig("playMusic.clickMusicList"); if (config === "replace") { trackPlayer.playMusicWithReplaceQueue( table.getRowModel().rows.map((it) => it.original), row.original, ); } else { trackPlayer.playMusic(row.original); } }} draggable={enableDrag} onDragStart={(e) => { // TODO // if(activeItems) { // } startDrag(e, virtualItem.rowIndex, "musiclist"); }} > {row.getVisibleCells().map((cell) => ( ))} ); })}
    {flexRender( header.column.columnDef.header, header.getContext(), )}
    {/*
    { e.stopPropagation(); }} className={classNames({ resizer: true, "resizer-resizing": header.column.getIsResizing(), })} >
    */}
    {flexRender(cell.column.columnDef.cell, cell.getContext())}
    } >
    ); } export default memo( _MusicList, (prev, curr) => prev.state === curr.state && prev.enableDrag === curr.enableDrag && prev.musicList === curr.musicList && prev.onPageChange === curr.onPageChange && prev.onDragEnd === curr.onDragEnd && prev.musicSheet && curr.musicSheet && isSameMedia(prev.musicSheet, curr.musicSheet), ); ================================================ FILE: src/renderer/components/MusicSheetlikeItem/index.scss ================================================ .components--albumlike-item-container { $width: 140px; $height: 216px; width: $width; height: $height; & .album-img-wrapper { width: $width; height: $width; -webkit-user-drag: none; border-radius: 8px; overflow: hidden; position: relative; & .album-play-info { position: absolute; box-sizing: border-box; left: 0; bottom: 0; background-color: rgba($color: #000000, $alpha: 0.1); backdrop-filter: blur(10px); width: 100%; height: 1.8rem; font-size: 0.85rem; display: flex; align-items: center; justify-content: space-between; color: #eee; padding-left: 4px; padding-right: 4px; white-space: nowrap; & .play-count { display: flex; align-items: center; & svg { margin-right: 2px; } } } & img { position: absolute; top: 0; left: 0; width: $width; height: $width; -webkit-user-drag: none; border-radius: 8px; object-fit: cover; transition: transform ease-out 400ms; &:hover { transform: scale(1.1); } } } & .media-info { margin-top: 6px; height: $height - $width - 6px; width: 100%; & .title { font-size: 1.1rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; &:hover { color: var(--primaryColor); } } & .author { font-size: 0.9rem; margin-top: 4px; display: flex; align-items: center; & span { opacity: 0.8; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } } } } ================================================ FILE: src/renderer/components/MusicSheetlikeItem/index.tsx ================================================ import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import "./index.scss"; import albumImg from "@/assets/imgs/album-cover.jpg"; import Condition from "../Condition"; import dayjs from "dayjs"; import SvgAsset from "../SvgAsset"; import { normalizeNumber } from "@/common/normalize-util"; import { memo } from "react"; import { isCN } from "@/shared/i18n/renderer"; interface IMusicSheetlikeItemProps { mediaItem: IMusic.IMusicSheetItem; onClick?: (mediaItem: IMusic.IMusicSheetItem) => void; } function MusicSheetlikeItem(props: IMusicSheetlikeItemProps) { const { mediaItem, onClick } = props; return (
    { onClick?.(mediaItem); }} >
    {mediaItem?.createAt ? ( dayjs(mediaItem.createAt).format("YYYY-MM-DD") ) : (
    )}
    {normalizeNumber(mediaItem?.playCount, !isCN())}
    {mediaItem?.title}
    {mediaItem?.artist ?? mediaItem?.description ?? ""}
    ); } export default memo( MusicSheetlikeItem, (prev, curr) => prev.mediaItem === curr.mediaItem && prev.onClick === curr.onClick, ); ================================================ FILE: src/renderer/components/MusicSheetlikeList/index.scss ================================================ .music-sheet-like-list--container { width: 100%; & .music-sheet-like-list--body { display: grid; width: 100%; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; } } ================================================ FILE: src/renderer/components/MusicSheetlikeList/index.tsx ================================================ import { RequestStateCode } from "@/common/constant"; import { memo } from "react"; import "./index.scss"; import BottomLoadingState from "@/renderer/components/BottomLoadingState"; import MusicSheetlikeItem from "@/renderer/components/MusicSheetlikeItem"; import Condition from "../Condition"; import Empty from "../Empty"; interface IMusicSheetlikeListProps { data: IMusic.IMusicSheetItem[]; state: RequestStateCode; onLoadMore?: () => void; onClick?: (mediaItem: IMusic.IMusicSheetItem) => void; } function MusicSheetlikeList(props: IMusicSheetlikeListProps) { const { data = [], state, onLoadMore, onClick } = props; return (
    }>
    {data.map((mediaItem, index) => { return ( { onClick?.(mediaItem); }} mediaItem={mediaItem} key={index} > ); })}
    ); } export default memo( MusicSheetlikeList, (prev, curr) => prev.data === curr.data && prev.state === curr.state, ); ================================================ FILE: src/renderer/components/MusicSheetlikeView/components/Body/index.scss ================================================ .music-sheetlike-view--body-container { & .operations { margin-top: 1rem; margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; & .buttons { display: flex; column-gap: 1rem; & .option-button { gap: 4px; & svg { width: 1.5em; height: 1.5em; } } } & .search-in-music-list-container { width: 280px; position: relative; display: flex; align-items: center; & .search-in-music-list { width: 100%; padding: 0.6rem calc(1.4rem + 12px) 0.6rem 0.8rem; border-radius: 4px; } & svg { opacity: 0.6; position: absolute; right: 6px; width: 1.4rem; height: 1.4rem; } } } } ================================================ FILE: src/renderer/components/MusicSheetlikeView/components/Body/index.tsx ================================================ import MusicList from "@/renderer/components/MusicList"; import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; import { ReactNode, useEffect, useState, useTransition } from "react"; import Condition from "@/renderer/components/Condition"; import Loading from "@/renderer/components/Loading"; import trackPlayer from "@renderer/core/track-player"; import { showModal } from "@/renderer/components/Modal"; import { RequestStateCode, localPluginName } from "@/common/constant"; import { offsetHeightStore } from "../../store"; import MusicSheet from "@/renderer/core/music-sheet"; import AppConfig from "@shared/app-config/renderer"; import { useTranslation } from "react-i18next"; interface IProps { musicSheet: IMusic.IMusicSheetItem; musicList: IMusic.IMusicItem[]; state?: RequestStateCode; onLoadMore?: () => void; options?: ReactNode; } export default function Body(props: IProps) { const { musicList = [], musicSheet, state, onLoadMore, options } = props; const [inputSearch, setInputSearch] = useState(""); const [filterMusicList, setFilterMusicList] = useState< IMusic.IMusicItem[] | null >(null); const [isPending, startTransition] = useTransition(); const { t } = useTranslation(); useEffect(() => { if (inputSearch.trim() === "") { setFilterMusicList(null); } else { startTransition(() => { const caseSensitive = AppConfig.getConfig( "playMusic.caseSensitiveInSearch", ); if (caseSensitive) { setFilterMusicList( musicList.filter( (item) => item.title?.includes(inputSearch) || item.artist?.includes(inputSearch) || item.album?.includes(inputSearch), ), ); } else { const searchText = inputSearch.toLocaleLowerCase(); setFilterMusicList( musicList.filter( (item) => item.title?.toLocaleLowerCase()?.includes(searchText) || item.artist?.toLocaleLowerCase()?.includes(searchText) || item.album?.toLocaleLowerCase()?.includes(searchText), ), ); } }); } }, [inputSearch]); useEffect(() => { setInputSearch(""); }, [musicSheet?.id]); return (
    { if (musicList.length) { trackPlayer.playMusicWithReplaceQueue(musicList); } }} > {t("music_sheet_like_view.play_all")}
    { showModal("AddMusicToSheet", { musicItems: musicList, }); }} > {t("music_sheet_like_view.add_to_sheet")}
    {options}
    { setInputSearch(evt.target.value); }} value={inputSearch} className="search-in-music-list" >
    } > musicList} // TODO: 过滤歌曲 musicSheet={musicSheet} state={state} onPageChange={onLoadMore} virtualProps={{ getScrollElement() { return document.querySelector("#page-container"); }, offsetHeight: () => offsetHeightStore.getValue(), }} enableDrag={musicSheet?.platform === localPluginName} onDragEnd={(newData) => { if (musicSheet?.platform === localPluginName && musicSheet?.id) { MusicSheet.frontend.updateSheetMusicOrder(musicSheet.id, newData); } }} >
    ); } ================================================ FILE: src/renderer/components/MusicSheetlikeView/components/Header/index.scss ================================================ .music-sheetlike-view--header-container { margin-top: 24px; display: flex; min-height: 160px; & img { width: 160px; height: 160px; border-radius: 8px; user-select: none; -webkit-user-drag: none; object-fit: cover; } & .sheet-info-container { margin-left: 1.5rem; flex: 1; display: flex; flex-direction: column; row-gap: 1rem; & .title-container{ display: flex; align-items: center; -webkit-user-drag: none; user-select: text; & .title { flex: 1; font-size: 1.8rem; font-weight: 600; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; // 左边有tag的情况 &:not(:first-child) { margin-left: 0.5rem; } } } & .description-container { font-size: 1rem; line-height: 2; &[data-fold="true"] { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } } & .info-container { font-size: 1rem; opacity: .8; line-height: 2; & span:not(:first-child)::before { content: " · "; } } } } ================================================ FILE: src/renderer/components/MusicSheetlikeView/components/Header/index.tsx ================================================ import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import albumImg from "@/assets/imgs/album-cover.jpg"; import "./index.scss"; import Tag from "@/renderer/components/Tag"; import Condition, { IfTruthy } from "@/renderer/components/Condition"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; import trackPlayer from "@renderer/core/track-player"; import SvgAsset from "@renderer/components/SvgAsset"; import { showModal } from "@renderer/components/Modal"; interface IProps { musicSheet: IMusic.IMusicSheetItem; musicList: IMusic.IMusicItem[]; hidePlatform?: boolean; } export default function Header(props: IProps) { const { musicSheet, musicList, hidePlatform } = props; const containerRef = useRef(); const { t } = useTranslation(); return (
    {musicSheet?.title}
    {(musicSheet?.platform && !hidePlatform) ? ( {musicSheet?.platform} ) : null}
    {musicSheet?.title ?? t("media.unknown_title")}
    { const dataset = e.currentTarget.dataset; dataset.fold = dataset.fold === "true" ? "false" : "true"; }} > {t("media.media_description")}: {musicSheet?.description}
    {t("media.media_play_count")} {musicSheet?.playCount} {t("media.media_create_at")} {dayjs(musicSheet?.createAt).format("YYYY-MM-DD")}
    {t("media.media_type_artist")} {musicSheet?.artist}
    ); } ================================================ FILE: src/renderer/components/MusicSheetlikeView/index.scss ================================================ .music-sheetlike-view--container { width: 100%; } ================================================ FILE: src/renderer/components/MusicSheetlikeView/index.tsx ================================================ import { ReactNode, useEffect } from "react"; import { RequestStateCode } from "@/common/constant"; import Body from "./components/Body"; import Header from "./components/Header"; import { initValue, offsetHeightStore } from "./store"; import "./index.scss"; interface IMusicSheetlikeViewProps { scrollElement?: HTMLElement; musicSheet: IMusic.IMusicSheetItem; musicList?: IMusic.IMusicItem[]; state?: RequestStateCode; onLoadMore?: () => void; options?: ReactNode; /** 是否展示来源tag */ hidePlatform?: boolean; } export default function MusicSheetlikeView(props: IMusicSheetlikeViewProps) { const { musicSheet, musicList, state = RequestStateCode.IDLE, onLoadMore, options, hidePlatform, } = props; useEffect(() => { return () => { offsetHeightStore.setValue(initValue); }; }, []); return (
    ); } ================================================ FILE: src/renderer/components/MusicSheetlikeView/store.ts ================================================ import { rem } from "@/common/constant"; import Store from "@/common/store"; export const initValue = 184 + 4 * rem; export const offsetHeightStore = new Store(initValue); ================================================ FILE: src/renderer/components/NoPlugin/index.scss ================================================ .no-plugin-container { width: 100%; flex: 1; min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 2; } ================================================ FILE: src/renderer/components/NoPlugin/index.tsx ================================================ import { Link } from "react-router-dom"; import "./index.scss"; import { Trans, useTranslation } from "react-i18next"; interface INoPluginProps { supportMethod?: string; height?: number | string; } export default function NoPlugin(props: INoPluginProps) { const { supportMethod, height } = props ?? {}; const { t } = useTranslation(); return (
    {supportMethod ? ( , }} values={{ supportMethod, }} > ) : ( t("plugin.info_hint_you_have_no_plugin") )} , }} >
    ); } ================================================ FILE: src/renderer/components/Panel/index.tsx ================================================ import Store from "@/common/store"; import templates from "./templates"; import { useMemo } from "react"; type ITemplate = typeof templates; type IPanelType = keyof ITemplate; interface IPanelInfo { type: IPanelType | null; payload: any; } const panelStore = new Store({ type: null, payload: null, }); export default function PanelComponent() { const modalState = panelStore.useValue(); return useMemo(() => { if (modalState.type) { const Component = templates[modalState.type]; return ; } return null; }, [modalState]); } export function showPanel( type: T, payload?: Parameters[0], ) { panelStore.setValue({ type, payload, }); } export function hidePanel() { panelStore.setValue({ type: null, payload: null, }); } export function getCurrentPanel(){ return panelStore.getValue(); } ================================================ FILE: src/renderer/components/Panel/templates/Base/index.scss ================================================ .components--panel-base { position: absolute; z-index: 10000; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: flex-end; background-color: var(--maskColor); cursor: default !important; &[data-cover-header=true] { top: calc(-1 * var(--appHeaderHeight)); } & .components--panel-base-content { border-top-left-radius: 8px; background-color: var(--backgroundColor); overflow-y: auto; width: 40vw; height: 100%; box-shadow: var(--shadow, var(--shadowColor) -2px 0px 2px); display: flex; flex-direction: column; & .components--panel-base-header { width: 100%; display: flex; height: 3rem; box-sizing: border-box; padding-left: 1rem; padding-right: 1rem; align-items: center; justify-content: space-between; font-size: 1.2rem; font-weight: 600; user-select: none; /* background-color: rgba($color: #000000, $alpha: 0.1); */ border-bottom: 1px solid var(--dividerColor); flex-shrink: 0; & .components--panel-base-header-close { $size: 18px; width: $size; height: $size; & svg { width: $size; height: $size; } } } } } ================================================ FILE: src/renderer/components/Panel/templates/Base/index.tsx ================================================ import { ReactNode, useEffect, useRef } from "react"; import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; import { hidePanel } from "../.."; interface IBaseModalProps { // 默认区域 onDefaultClick?: () => void; // 点击默认区域时关闭 defaultClose?: boolean; // 模糊 withBlur?: boolean; /** mask区域颜色 */ maskColor?: string; /** 标题 */ title?: ReactNode; width?: string | number; scrollable?: boolean; children: ReactNode; coverHeader?: boolean; } const baseId = "components--panel-base-container"; function Base(props: IBaseModalProps) { const { onDefaultClick, defaultClose = true, maskColor, children, withBlur = false, width, scrollable = true, coverHeader = false, } = props; const trapCloseRef = useRef(false); return (
    { if ((e.target as HTMLElement)?.id === baseId) { trapCloseRef.current = true; } else { trapCloseRef.current = false; } }} onMouseUp={(e) => { if ((e.target as HTMLElement)?.id === baseId && trapCloseRef.current) { if (defaultClose) { hidePanel(); } else { onDefaultClick?.(); } } }} onMouseLeave={() => { trapCloseRef.current = false; }} onMouseOut={() => { trapCloseRef.current = false; }} >
    {children}
    ); } interface IHeaderProps { children: ReactNode; right?: ReactNode; } function Header(props: IHeaderProps) { const { children, right } = props; return (
    {children} {right ?? (
    { hidePanel(); }} >
    )}
    ); } Base.Header = Header; export default Base; ================================================ FILE: src/renderer/components/Panel/templates/MusicComment/index.scss ================================================ .music-comment-panel--title-container { margin: 24px 16px 8px; font-size: 1.2rem; font-weight: 600; } .music-comment-panel--body-container { margin-bottom: 16px; } .music-comment-panel--comment-item-container { padding: 16px; display: flex; flex-direction: column; gap: 8px; & .comment-title-container { display: flex; align-items: center; gap: 8px; font-size: 1.1em; & .avatar { width: 32px; height: 32px; border-radius: 50%; } & span { user-select: auto; } } & .comment-body-container { padding-left: 40px; user-select: text; cursor: text; line-height: 1.5em; } & .comment-operations-container{ display: flex; justify-content: space-between; padding-left: 40px; opacity: 0.7; & .thumb-up { display: flex; align-items: center; & svg { width: 1.1em; height: 1.1em; } & span { margin-left: 4px; } } } } ================================================ FILE: src/renderer/components/Panel/templates/MusicComment/index.tsx ================================================ import Base from "@renderer/components/Panel/templates/Base"; import "./index.scss"; import { useTranslation } from "react-i18next"; import SvgAsset from "@renderer/components/SvgAsset"; import dayjs from "dayjs"; import useComment from "@renderer/components/Panel/templates/MusicComment/useComment"; import { RequestStateCode } from "@/common/constant"; import Loading from "@renderer/components/Loading"; import BottomLoadingState from "@renderer/components/BottomLoadingState"; interface IProps { coverHeader?: boolean; musicItem?: IMusic.IMusicItem; } export default function MusicComment(props: IProps) { const { coverHeader, musicItem } = props; const { t } = useTranslation(); const [comments, reqState, loadMore] = useComment(musicItem); return
    {t("media.media_type_comment")}
    {(comments.length === 0 && (reqState & RequestStateCode.LOADING)) ? : <> {comments.map(comment => )} }
    ; } interface IMusicCommentItemProps { comment: IComment.IComment } function MusicCommentItem(props: IMusicCommentItemProps) { const { comment } = props; return
    {comment.nickName}
    {comment.comment}
    {comment.createAt ? {dayjs(comment.createAt).format("YYYY-MM-DD")} : null}
    {comment.like ?? "-"}
    ; } ================================================ FILE: src/renderer/components/Panel/templates/MusicComment/useComment.ts ================================================ import { useEffect, useRef, useState } from "react"; import { RequestStateCode } from "@/common/constant"; import PluginManager from "@shared/plugin-manager/renderer"; export default function useComment(musicItem: IMusic.IMusicItem) { const [comments, setComments] = useState([]); const [requestStateCode, setRequestStateCode] = useState(RequestStateCode.IDLE); const pageRef = useRef(1); const loadMore = async () => { try { if (requestStateCode & RequestStateCode.LOADING) { return; } setRequestStateCode(comments.length > 0 ? RequestStateCode.PENDING_REST_PAGE : RequestStateCode.PENDING_FIRST_PAGE); const response = await PluginManager.callPluginDelegateMethod(musicItem, "getMusicComments", musicItem, pageRef.current); setComments(prev => prev.concat(response.data ?? [])); if (response.isEnd === false) { setRequestStateCode(RequestStateCode.PARTLY_DONE); pageRef.current = pageRef.current + 1; } else { setRequestStateCode(RequestStateCode.FINISHED); } } catch { setRequestStateCode(RequestStateCode.ERROR); } }; useEffect(() => { loadMore(); }, []); return [comments, requestStateCode, loadMore] as const; } ================================================ FILE: src/renderer/components/Panel/templates/PlayList/index.scss ================================================ .playlist--header { box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; padding-left: 14px; padding-right: 14px; margin-top: 12px; & .playlist--title { font-size: 1.2rem; font-weight: 600; } } .playlist--music-list-container { flex: 1; overflow-y: auto; overflow-x: hidden; } .playlist--music-list-scroll { position: relative; width: 100%; outline: none; } .playlist--divider { width: 100%; height: 1px; background-color: var(--dividerColor); margin-top: 12px; margin-bottom: 0; } .play-list--music-item-container { width: 460px; height: 2.6rem; display: flex; align-items: center; padding-left: 14px; padding-right: 14px; box-sizing: border-box; &[data-active="true"] { background: var(--listActiveColor); } & .playlist--options { display: flex; gap: 4px; } & .playlist--title { margin-left: 8px; width: 180px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 0; } & .playlist--artist { margin-left: 8px; width: 106px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 0; } & .playlist--platform { margin-left: 8px; flex-basis: 0; flex-grow: 1; width: 0; } & .playlist--remove { flex-shrink: 0; margin-left: 8px; height: 16px; width: 16px; } &:nth-child(even) { background-color: rgba($color: #000000, $alpha: 0.05); } &:hover { background: var(--listHoverColor); } } ================================================ FILE: src/renderer/components/Panel/templates/PlayList/index.tsx ================================================ import "./index.scss"; import { memo, useEffect, useRef, useState } from "react"; import trackPlayer from "@renderer/core/track-player"; import Condition, { IfTruthy } from "@/renderer/components/Condition"; import Empty from "@/renderer/components/Empty"; import { getMediaPrimaryKey, isSameMedia } from "@/common/media-util"; import MusicFavorite from "@/renderer/components/MusicFavorite"; import Tag from "@/renderer/components/Tag"; import SvgAsset from "@/renderer/components/SvgAsset"; import useVirtualList from "@/hooks/useVirtualList"; import { rem } from "@/common/constant"; import { showMusicContextMenu } from "@/renderer/components/MusicList"; import MusicDownloaded from "@/renderer/components/MusicDownloaded"; import Base from "../Base"; import hotkeys from "hotkeys-js"; import { Trans, useTranslation } from "react-i18next"; import DragReceiver, { startDrag } from "@/renderer/components/DragReceiver"; import { useCurrentMusic, useMusicQueue } from "@renderer/core/track-player/hooks"; const estimateItemHeight = 2.6 * rem; const DRAG_TAG = "Playlist"; interface IProps { coverHeader?: boolean; } export default function PlayList(props: IProps) { const { coverHeader } = props; const musicQueue = useMusicQueue(); const currentMusic = useCurrentMusic(); const scrollElementRef = useRef(); const [activeItems, setActiveItems] = useState>(new Set()); const lastActiveIndexRef = useRef(0); const { t } = useTranslation(); const virtualController = useVirtualList({ estimateItemHeight: estimateItemHeight, data: musicQueue, getScrollElement() { return scrollElementRef.current; }, fallbackRenderCount: 0, }); useEffect(() => { virtualController.setScrollElement(scrollElementRef.current); const currentMusic = trackPlayer.currentMusic; if (currentMusic) { const queue = trackPlayer.musicQueue; const index = queue.findIndex((it) => isSameMedia(it, currentMusic)); if (index > 4) { virtualController.scrollToIndex(index - 4); } } const ctrlAHandler = (evt: Event) => { evt.preventDefault(); const queue = trackPlayer.musicQueue; setActiveItems(new Set(Array.from({ length: queue.length }, (_, i) => i))); }; hotkeys("Ctrl+A", "play-list", ctrlAHandler); return () => { hotkeys.unbind("Ctrl+A", ctrlAHandler); }; }, []); const onDrop = (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex) { // 没有移动 return; } const newData = musicQueue .slice(0, fromIndex) .concat(musicQueue.slice(fromIndex + 1)); newData.splice( fromIndex > toIndex ? toIndex : toIndex - 1, 0, musicQueue[fromIndex], ); trackPlayer.setMusicQueue(newData); }; useEffect(() => { setActiveItems(new Set()); }, [musicQueue]); return (
    { trackPlayer.reset(); }} > {t("common.clear")}
    }>
    { hotkeys.setScope("play-list"); }} onBlur={() => { hotkeys.setScope("all"); }} > {virtualController.virtualItems.map((virtualItem) => { const musicItem = virtualItem.dataItem; const rowIndex = virtualItem.rowIndex; return (
    { startDrag(e, rowIndex, DRAG_TAG); }} onDoubleClick={() => { trackPlayer.playMusic(musicItem); }} onContextMenu={(e) => { if ( activeItems.size > 1 ) { const selectedItems: IMusic.IMusicItem[] = []; activeItems.forEach(item => { selectedItems.push(musicQueue[item]); }); showMusicContextMenu( selectedItems, e.clientX, e.clientY, "play-list", ); } else { lastActiveIndexRef.current = virtualItem.rowIndex; setActiveItems(new Set([virtualItem.rowIndex])); showMusicContextMenu( musicItem, e.clientX, e.clientY, "play-list", ); } }} onClick={() => { // 如果点击的时候按下shift if (hotkeys.shift) { let start = lastActiveIndexRef.current; let end = virtualItem.rowIndex; if (start >= end) { [start, end] = [end, start]; } if (end > musicQueue.length) { end = musicQueue.length - 1; } setActiveItems( new Set( Array.from({ length: end - start + 1 }, (_, i) => start + i), ), ); } else if (hotkeys.ctrl) { const newSet = new Set(activeItems); if (newSet.has(virtualItem.rowIndex)) { newSet.delete(virtualItem.rowIndex); } else { newSet.add(virtualItem.rowIndex); } setActiveItems(newSet); } else { setActiveItems(new Set([virtualItem.rowIndex])); lastActiveIndexRef.current = virtualItem.rowIndex; } }} >
    ); })}
    ); } interface IPlayListMusicItemProps { isPlaying: boolean; musicItem: IMusic.IMusicItem; isActive?: boolean; } function _PlayListMusicItem(props: IPlayListMusicItemProps) { const { isPlaying, musicItem, isActive } = props; if (!musicItem) { return null; } return (
    {musicItem?.title ?? "-"}
    {musicItem?.artist ?? "-"}
    {musicItem?.platform}
    { trackPlayer.removeMusic(musicItem); }} >
    ); } const PlayListMusicItem = memo( _PlayListMusicItem, (prev, curr) => prev.isPlaying === curr.isPlaying && prev.musicItem === curr.musicItem && prev.isActive === curr.isActive, ); ================================================ FILE: src/renderer/components/Panel/templates/UserVariables/index.scss ================================================ .panel--user-variables-submit { font-weight: 400; padding: 0.4rem 0.6rem; font-size: 1rem; border-radius: 8px; color: var(--infoColor, #0A95C8); border: 1px solid currentColor; } .panel--user-variables-container { height: 100%; width: 100%; overflow-y: auto; & .panel--user-variable-item { width: 100%; height: 3rem; display: flex; align-items: center; padding: 0 12px; box-sizing: border-box; & span { width: 6rem; margin-right: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 0; } & input { flex: 1; } } } ================================================ FILE: src/renderer/components/Panel/templates/UserVariables/index.tsx ================================================ import { useRef } from "react"; import Base from "../Base"; import "./index.scss"; import { hidePanel } from "../.."; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; import AppConfig from "@shared/app-config/renderer"; interface IUserVariablesProps { plugin: IPlugin.IPluginDelegate; variables: IPlugin.IUserVariable[]; initValues?: Record; } export default function (props: IUserVariablesProps) { const { variables = [], initValues = {}, plugin } = props; const valueRef = useRef>({ ...(initValues ?? {}) }); const { t } = useTranslation(); return ( { const currentConfig = AppConfig.getConfig("private.pluginMeta") || {}; const currentPluginConfig = currentConfig?.[plugin.platform] ?? {}; currentPluginConfig.userVariables = valueRef.current; currentConfig[plugin.platform] = currentPluginConfig; AppConfig.setConfig({ "private.pluginMeta": currentConfig, }); hidePanel(); toast.success(t("panel.user_variable_setting_success")); }} > {t("common.confirm")} } > {plugin.platform ?? ""} {t("panel.user_variable")}
    {variables.map((variable) => (
    {variable.name ?? variable.key} { valueRef.current[variable.key] = ( e.target as HTMLInputElement ).value; }} placeholder={variable.hint} >
    ))}
    ); } ================================================ FILE: src/renderer/components/Panel/templates/index.ts ================================================ import Base from "./Base"; import PlayList from "./PlayList"; import UserVariables from "./UserVariables"; import MusicComment from "./MusicComment"; export default { Base, UserVariables, PlayList, MusicComment, }; ================================================ FILE: src/renderer/components/SvgAsset/index.tsx ================================================ import { memo } from "react"; export type SvgAssetIconNames = | "album" | "array-download-tray" | "arrow-left-end-on-rectangle" | "cd" | "chat-bubble-left-ellipsis" | "check" | "check-circle" | "chevron-double-down" | "chevron-double-up" | "chevron-down" | "chevron-left" | "chevron-right" | "clock" | "code-bracket-square" | "cog-8-tooth" | "dashboard-speed" | "document-plus" | "fire" | "folder-open" | "font-size-larger" | "font-size-smaller" | "hand-thumb-up" | "headphone" | "heart-outline" | "heart" | "identification" | "language" | "list-bullet" | "lock-closed" | "lock-open" | "logo" | "lyric" | "lyric-en" | "magnifying-glass" | "minus" | "motion-play" | "musical-note" | "pause" | "pencil-square" | "picture-in-picture-line" | "play" | "playlist" | "plus" | "plus-circle" | "question-mark-circle" | "repeat-song-1" | "repeat-song" | "rolling-1s" | "shuffle" | "skip-left" | "skip-right" | "sort" | "sort-asc" | "sort-desc" | "sparkles" | "speaker-wave" | "speaker-x-mark" | "square" | "trash" | "trophy" | "t-shirt-line" | "user" | "lq" | "sd" | "hq" | "sq" | "x-mark"; interface IProps { iconName: SvgAssetIconNames; size?: number; title?: string; color?: string; } /** * * @param props * @returns */ function SvgAsset(props: IProps) { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const Svg = require(`@/assets/icons/${props.iconName}.svg`); return ( ); } export default memo(SvgAsset, (prev, curr) => prev.iconName === curr.iconName); ================================================ FILE: src/renderer/components/SwitchCase/index.tsx ================================================ import { ReactElement } from "react"; interface ISwitchProps { switch: any; children: any; } function Switch(props: ISwitchProps){ const { switch: _switch, children } = props; if (Array.isArray(children)) { const validChildren = children.filter( (child) => child.props?.case === _switch, ); return validChildren as ReactElement[]; } return children.props?.case === _switch ? children : null; } interface ICaseProps { case: any; children: any; } function Case(props: ICaseProps) { const { children } = props; return children; } const SwitchCase = { Switch, Case, }; export default SwitchCase; ================================================ FILE: src/renderer/components/Tag/index.scss ================================================ .components--tag-container { font-size: 0.9rem; color: var(--primaryColor); border: 1px solid var(--primaryColor); border-radius: 12px; padding: 2px 6px; width: fit-content; max-width: 7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; flex-shrink: 0; &[data-fill=true] { background-color: var(--primaryColor); color: white; } } ================================================ FILE: src/renderer/components/Tag/index.tsx ================================================ import { CSSProperties, ReactNode } from "react"; import "./index.scss"; interface ITagProps { fill?: boolean; children: ReactNode; style?: CSSProperties } export default function Tag(props: ITagProps) { return (
    {props.children}
    ); } ================================================ FILE: src/renderer/core/backup-resume/index.ts ================================================ import MusicSheet from "../music-sheet"; /** * 恢复 * @param data 数据 * @param overwrite 是否覆写歌单 */ async function resume(data: string | Record, overwrite?: boolean) { const dataObj = typeof data === "string" ? JSON.parse(data) : data; const currentSheets = MusicSheet.frontend.getAllSheets(); const allSheets: IMusic.IMusicSheetItem[] = dataObj.musicSheets; let importedDefaultSheet; for (const sheet of allSheets) { if (overwrite && sheet.id === MusicSheet.defaultSheet.id) { importedDefaultSheet = sheet; continue; } const newSheet = await MusicSheet.frontend.addSheet(sheet.title); await MusicSheet.frontend.addMusicToSheet(sheet.musicList, newSheet.id); } if (overwrite) { for (const sheet of currentSheets) { if (sheet.id === MusicSheet.defaultSheet.id) { if (importedDefaultSheet) { await MusicSheet.frontend.clearSheet(MusicSheet.defaultSheet.id); await MusicSheet.frontend.addMusicToFavorite( importedDefaultSheet.musicList, ); } } await MusicSheet.frontend.removeSheet(sheet.id); } } } const BackupResume = { resume, }; export default BackupResume; ================================================ FILE: src/renderer/core/db/music-sheet-db.ts ================================================ import { musicRefSymbol } from "@/common/constant"; import Dexie, { Table } from "dexie"; class MusicSheetDB extends Dexie { // 歌单信息,其中musiclist只存有platform和id sheets: Table; // musicstore 存有歌单内保存所有的音乐信息 musicStore: Table< IMusic.IMusicItem & { [musicRefSymbol]: number; // 某个歌曲在歌单中被引用几次,数字 } >; localMusicStore: Table; constructor() { super("musicSheetDB"); this.version(1.1).stores({ sheets: "&id, title, artist, createAt, $$sortIndex", musicStore: "[platform+id], title, artist, album", /** 本地音乐 */ localMusicStore: "[platform+id], title, artist, album, $$localPath", }); } } const musicSheetDB = new MusicSheetDB(); export default musicSheetDB; ================================================ FILE: src/renderer/core/downloader/downloaded-sheet.ts ================================================ import { getInternalData, getMediaPrimaryKey, isSameMedia, setInternalData, } from "@/common/media-util"; import Store from "@/common/store"; import { getUserPreferenceIDB, setUserPreferenceIDB, } from "@/renderer/utils/user-perference"; import musicSheetDB from "../db/music-sheet-db"; import { internalDataKey, musicRefSymbol } from "@/common/constant"; import { useEffect, useState } from "react"; import { DownloadEvts, ee } from "./ee"; import { fsUtil } from "@shared/utils/renderer"; const downloadedMusicListStore = new Store([]); const downloadedSet = new Set(); // 在初始化歌单时一起初始化 export async function setupDownloadedMusicList() { const downloadedPKs = (await getUserPreferenceIDB("downloadedList")) ?? []; downloadedMusicListStore.setValue(await getDownloadedDetails(downloadedPKs)); downloadedPKs.forEach((it) => { downloadedSet.add(getMediaPrimaryKey(it)); }); } async function getDownloadedDetails(mediaBases: IMedia.IMediaBase[]) { return await musicSheetDB.transaction( "readonly", musicSheetDB.musicStore, async () => { const musicDetailList = await musicSheetDB.musicStore.bulkGet( mediaBases.map((item) => [item.platform, item.id]), ); return musicDetailList; }, ); } function primaryKeyMap(media: IMedia.IMediaBase) { return { platform: media.platform, id: media.id, }; } // 添加到已下载完成的列表中 export async function addDownloadedMusicToList( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], ) { const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; try { // 筛选出不在列表中的项目 const targetMusicList = downloadedMusicListStore.getValue(); const validMusicItems = _musicItems.filter( (item) => -1 === targetMusicList.findIndex((mi) => isSameMedia(mi, item)), ); await musicSheetDB.transaction("rw", musicSheetDB.musicStore, async () => { // 寻找已入库的音乐项目 const allMusic = await musicSheetDB.musicStore.bulkGet( validMusicItems.map((item) => [item.platform, item.id]), ); allMusic.forEach((mi, index) => { if (mi) { mi[musicRefSymbol] += 1; mi[internalDataKey] = { ...(mi[internalDataKey] ?? {}), ...(validMusicItems[index][internalDataKey] ?? {}), }; } else { allMusic[index] = { ...validMusicItems[index], [musicRefSymbol]: 1, }; } }); await musicSheetDB.musicStore.bulkPut(allMusic); downloadedMusicListStore.setValue((prev) => [...prev, ...allMusic]); allMusic.forEach((it) => { downloadedSet.add(getMediaPrimaryKey(it)); }); ee.emit(DownloadEvts.Downloaded, allMusic); setUserPreferenceIDB( "downloadedList", downloadedMusicListStore.getValue().map(primaryKeyMap), ); return true; }); } catch { console.log("error!!"); return false; } } export async function removeDownloadedMusic( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], removeFile = false, ): Promise { const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; let message: string | null = null; try { // 1. 获取全部详细信息 const toBeRemovedMusicDetail = await musicSheetDB.transaction( "r", musicSheetDB.musicStore, async () => { return await musicSheetDB.musicStore.bulkGet( _musicItems.map((item) => [item.platform, item.id]), ); }, ); // 2. 删除文件,事务中删除会报错 let removeResults: boolean[] = []; if (removeFile) { removeResults = await Promise.all( toBeRemovedMusicDetail.map((it) => { try { return fsUtil.rimraf( getInternalData(it, "downloadData") ?.path, ); } catch (e) { // 删除失败 message = "部分歌曲删除失败 " + (e?.message ?? ""); return false; } }), ); } // 3. 修改数据库 await musicSheetDB.transaction("rw", musicSheetDB.musicStore, async () => { const needDelete: any[] = []; const needUpdate: any[] = []; await Promise.all( toBeRemovedMusicDetail.map(async (musicItem, index) => { if (!musicItem) { return; } // 1. 如果本地文件删除失败 if (removeFile && !removeResults[index]) { return; } // 只从歌单中删除,引用-1 musicItem[musicRefSymbol]--; if (musicItem[musicRefSymbol] === 0) { needDelete.push([musicItem.platform, musicItem.id]); } else { // 清空下载 setInternalData( musicItem, "downloadData", undefined, ); needUpdate.push(musicItem); } }), ); console.log(needUpdate); await musicSheetDB.musicStore.bulkDelete(needDelete); await musicSheetDB.musicStore.bulkPut(needUpdate); downloadedMusicListStore.setValue((prev) => prev.filter( (it) => -1 === _musicItems.findIndex((_) => isSameMedia(_, it)), ), ); // 触发事件 ee.emit(DownloadEvts.RemoveDownload, _musicItems); _musicItems.forEach((it) => { downloadedSet.delete(getMediaPrimaryKey(it)); }); setUserPreferenceIDB( "downloadedList", downloadedMusicListStore.getValue(), ); }); } catch (e) { message = "删除失败 " + (e?.message ?? ""); } if (message) { return [ false, { msg: message, }, ]; } else { return [true]; } } export function isDownloaded(musicItem: IMedia.IMediaBase) { return musicItem ? downloadedSet.has(getMediaPrimaryKey(musicItem)) : false; } export const useDownloadedMusicList = downloadedMusicListStore.useValue; export function useDownloaded(musicItem: IMedia.IMediaBase) { const [downloaded, setDownloaded] = useState(isDownloaded(musicItem)); useEffect(() => { const dlCb = (musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) => { if (Array.isArray(musicItems)) { setDownloaded( (prev) => prev || musicItems.findIndex((it) => isSameMedia(it, musicItem)) !== -1, ); } else { setDownloaded((prev) => prev || isSameMedia(musicItem, musicItems)); } }; const rmCb = (musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) => { if (Array.isArray(musicItems)) { setDownloaded( (prev) => prev && musicItems.findIndex((it) => isSameMedia(it, musicItem)) === -1, ); } else { setDownloaded((prev) => prev && !isSameMedia(musicItem, musicItems)); } }; if (musicItem) { setDownloaded(isDownloaded(musicItem)); } ee.on(DownloadEvts.Downloaded, dlCb); ee.on(DownloadEvts.RemoveDownload, rmCb); return () => { ee.off(DownloadEvts.Downloaded, dlCb); ee.off(DownloadEvts.RemoveDownload, rmCb); }; }, [musicItem]); return downloaded; } ================================================ FILE: src/renderer/core/downloader/ee.ts ================================================ import EventEmitter from "eventemitter3"; export const ee = new EventEmitter(); export enum DownloadEvts { DownloadStatusUpdated = "DownloadStatusUpdated", Downloaded = "Downloaded", RemoveDownload = "RemoveDownload", } ================================================ FILE: src/renderer/core/downloader/index.new.ts ================================================ import * as Comlink from "comlink"; import { getGlobalContext } from "@shared/global-context/renderer"; import AppConfig from "@shared/app-config/renderer"; import { addDownloadedMusicToList, isDownloaded, setupDownloadedMusicList, } from "@renderer/core/downloader/downloaded-sheet"; import logger from "@shared/logger/renderer"; import PQueue from "p-queue"; import EventEmitter from "eventemitter3"; import { DownloadState, localPluginName } from "@/common/constant"; import { getQualityOrder, isSameMedia, setInternalData } from "@/common/media-util"; import { downloadingMusicStore } from "@renderer/core/downloader/store"; import PluginManager from "@shared/plugin-manager/renderer"; type ProxyMarkedFunction = T & Comlink.ProxyMarked; interface IDownloadFileOptions { onProgress?: (progress: ICommon.IDownloadFileSize) => void; onEnded?: () => void; onError?: (reason: Error) => void; } interface IDownloaderWorker { downloadFileNew: (mediaSource: IMusic.IMusicSource, filePath: string, options?: ProxyMarkedFunction) => void } export enum DownloaderEvent { DOWNLOAD_STATE_CHANGED = "downloader:download-state-changed", QUEUE_UPDATED = "queue_updated", } interface IDownloaderEvent { [DownloaderEvent.DOWNLOAD_STATE_CHANGED]: (musicItem: IMusic.IMusicItem, status: ITaskStatus) => void; } interface ITaskStatus { status: DownloadState, progress?: ICommon.IDownloadFileSize, error?: Error } class Downloader extends EventEmitter { private worker: IDownloaderWorker; private static ConcurrencyLimit = 20; private downloadTaskQueue: PQueue; private currentTaskStatus: Map> = new Map(); public isReady = false; constructor() { super(); this.on(DownloaderEvent.DOWNLOAD_STATE_CHANGED, (...args) => { console.log("DOWNLOAD STATE CHANGE", ...args); console.log(this.downloadTaskQueue); }); } public async setup() { // 1. config const downloadConcurrency = AppConfig.getConfig("download.concurrency"); // 2. init worker const workerPath = getGlobalContext().workersPath.downloader; if (workerPath) { const worker = new Worker(workerPath); this.worker = Comlink.wrap(worker); this.isReady = true; } else { logger.logInfo("Worker path is not defined"); } // 3. setup downloading queue this.downloadTaskQueue = new PQueue({ concurrency: downloadConcurrency || 5, autoStart: false, }); // @ts-ignore window.dd = this.downloadTaskQueue; // 4. setup musicsheet setupDownloadedMusicList(); } public async download(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) { if (!this.worker) { await this.setup(); } const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; // 过滤掉已下载的、本地音乐、任务中的音乐 const _validMusicItems = _musicItems.filter( (it) => !isDownloaded(it) && it.platform !== localPluginName, ); const downloadTasks = _validMusicItems.map((it) => { this.setTaskStatus(it, { status: DownloadState.WAITING, }); const task = async () => { if (!this.getTaskStatus(it)) { return; } this.setTaskStatus(it, { status: DownloadState.DOWNLOADING, progress: { currentSize: NaN, totalSize: NaN, }, }); const fileName = `${it.title}-${it.artist}`.replace(/[/|\\?*"<>:]/g, "_"); await new Promise((resolve) => { this.downloadMusicImpl(it, fileName, { onError: (e) => { this.setTaskStatus(it, { status: DownloadState.ERROR, error: e, }); resolve(); }, onProgress: (progress) => { this.setTaskStatus(it, { status: DownloadState.DOWNLOADING, progress, }); }, onEnded: () => { this.setTaskStatus(it, { status: DownloadState.DONE, }); downloadingMusicStore.setValue((prev) => prev.filter((di) => !isSameMedia(it, di)), ); resolve(); }, }).catch((e) => { this.setTaskStatus(it, { status: DownloadState.ERROR, error: e, }); resolve(); }); }); }; task.musicItem = it; return task; }); this.downloadTaskQueue.addAll(downloadTasks); downloadingMusicStore.setValue((prev) => [...prev, ..._validMusicItems]); } private async downloadMusicImpl(musicItem: IMusic.IMusicItem, fileName: string, options: IDownloadFileOptions) { // 1. config const [defaultQuality, whenQualityMissing] = [ AppConfig.getConfig("download.defaultQuality"), AppConfig.getConfig("download.whenQualityMissing"), ]; const downloadBasePath = AppConfig.getConfig("download.path") ?? getGlobalContext().appPath.downloads; const qualityOrder = getQualityOrder(defaultQuality, whenQualityMissing); let mediaSource: IPlugin.IMediaSourceResult | null = null; let realQuality: IMusic.IQualityKey = qualityOrder[0]; for (const quality of qualityOrder) { try { mediaSource = await PluginManager.callPluginDelegateMethod( musicItem, "getMediaSource", musicItem, quality, ); if (!mediaSource?.url) { continue; } realQuality = quality; break; } catch { // pass } } if (mediaSource?.url) { const ext = mediaSource.url.match(/.*\/.+\.([^./?#]+)/)?.[1] ?? "mp3"; const downloadPath = window.path.resolve( downloadBasePath, `./${fileName}.${ext}`, ); this.worker.downloadFileNew( mediaSource, downloadPath, Comlink.proxy({ onError(reason) { options?.onError(reason); }, onProgress(progress) { options?.onProgress?.(progress); }, onEnded() { options?.onEnded?.(); addDownloadedMusicToList( setInternalData( musicItem as any, "downloadData", { path: downloadPath, quality: realQuality, }, true, ) as IMusic.IMusicItem, ); }, }), ); } else { throw new Error("Invalid Source"); } } public setConcurrency(concurrency: number) { if (this.downloadTaskQueue) { this.downloadTaskQueue.concurrency = Math.min( concurrency < 1 ? 1 : concurrency, Downloader.ConcurrencyLimit, ); } } public getTaskStatus(musicItem: IMusic.IMusicItem): ITaskStatus | null { const platform = "" + musicItem.platform; const id = "" + musicItem.id; return this.currentTaskStatus.get(platform)?.get(id) ?? null; } private setTaskStatus(musicItem: IMusic.IMusicItem, taskStatus: ITaskStatus) { const platform = "" + musicItem.platform; const id = "" + musicItem.id; if (!this.currentTaskStatus.has(platform)) { this.currentTaskStatus.set(platform, new Map()); } if (taskStatus.status === DownloadState.DONE) { this.currentTaskStatus.get(platform)?.delete(id); } else { this.currentTaskStatus.get(platform)?.set(id, taskStatus); } this.emit(DownloaderEvent.DOWNLOAD_STATE_CHANGED, musicItem, taskStatus); } } export default new Downloader(); ================================================ FILE: src/renderer/core/downloader/index.ts ================================================ import { getMediaPrimaryKey, getQualityOrder, isSameMedia, setInternalData, } from "@/common/media-util"; import * as Comlink from "comlink"; import { DownloadState, localPluginName } from "@/common/constant"; import PQueue from "p-queue"; import { addDownloadedMusicToList, isDownloaded, removeDownloadedMusic, setupDownloadedMusicList, useDownloaded, useDownloadedMusicList, } from "./downloaded-sheet"; import { getGlobalContext } from "@/shared/global-context/renderer"; import Store from "@/common/store"; import { useEffect, useState } from "react"; import { DownloadEvts, ee } from "./ee"; import AppConfig from "@shared/app-config/renderer"; import PluginManager from "@shared/plugin-manager/renderer"; export interface IDownloadStatus { state: DownloadState; downloaded?: number; total?: number; msg?: string; } const downloadingMusicStore = new Store>([]); const downloadingProgress = new Map(); type ProxyMarkedFunction void> = T & Comlink.ProxyMarked; type IOnStateChangeFunc = (data: IDownloadStatus) => void; interface IDownloaderWorker { downloadFile: ( mediaSource: IMusic.IMusicSource, filePath: string, onStateChange: ProxyMarkedFunction ) => Promise; } let downloaderWorker: IDownloaderWorker; async function setupDownloader() { setupDownloaderWorker(); setupDownloadedMusicList(); } function setupDownloaderWorker() { // 初始化worker const downloaderWorkerPath = getGlobalContext().workersPath.downloader; if (downloaderWorkerPath) { const worker = new Worker(downloaderWorkerPath); downloaderWorker = Comlink.wrap(worker); } setDownloadingConcurrency(AppConfig.getConfig("download.concurrency")); } const concurrencyLimit = 20; const downloadingQueue = new PQueue({ concurrency: 5, }); function setDownloadingConcurrency(concurrency: number) { if (isNaN(concurrency)) { return; } downloadingQueue.concurrency = Math.min( concurrency < 1 ? 1 : concurrency, concurrencyLimit, ); } async function startDownload( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], ) { if (!downloaderWorker) { setupDownloaderWorker(); } const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; // 过滤掉已下载的、本地音乐、任务中的音乐 const _validMusicItems = _musicItems.filter( (it) => !isDownloaded(it) && it.platform !== localPluginName, ); const downloadCallbacks = _validMusicItems.map((it) => { const pk = getMediaPrimaryKey(it); downloadingProgress.set(pk, { state: DownloadState.WAITING, }); return async () => { // Not on waiting list if (!downloadingProgress.has(pk)) { return; } downloadingProgress.get(pk).state = DownloadState.DOWNLOADING; const fileName = `${it.title}-${it.artist}`.replace(/[/|\\?*"<>:]/g, "_"); await new Promise((resolve) => { downloadMusicImpl(it, fileName, (stateData) => { downloadingProgress.set(pk, stateData); ee.emit(DownloadEvts.DownloadStatusUpdated, it, stateData); if (stateData.state === DownloadState.DONE) { downloadingMusicStore.setValue((prev) => prev.filter((di) => !isSameMedia(it, di)), ); downloadingProgress.delete(pk); resolve(); } else if (stateData.state === DownloadState.ERROR) { resolve(); } }); }); }; }); downloadingMusicStore.setValue((prev) => [...prev, ..._validMusicItems]); downloadingQueue.addAll(downloadCallbacks); } async function downloadMusicImpl( musicItem: IMusic.IMusicItem, fileName: string, onStateChange: IOnStateChangeFunc, ) { const [defaultQuality, whenQualityMissing] = [ AppConfig.getConfig("download.defaultQuality"), AppConfig.getConfig("download.whenQualityMissing"), ]; const qualityOrder = getQualityOrder(defaultQuality, whenQualityMissing); let mediaSource: IPlugin.IMediaSourceResult | null = null; let realQuality: IMusic.IQualityKey = qualityOrder[0]; for (const quality of qualityOrder) { try { mediaSource = await PluginManager.callPluginDelegateMethod( musicItem, "getMediaSource", musicItem, quality, ); if (!mediaSource?.url) { continue; } realQuality = quality; break; } catch {} } try { if (mediaSource?.url) { const ext = mediaSource.url.match(/.*\/.+\.([^./?#]+)/)?.[1] ?? "mp3"; const downloadBasePath = AppConfig.getConfig("download.path") ?? getGlobalContext().appPath.downloads; const downloadPath = window.path.resolve( downloadBasePath, `./${fileName}.${ext}`, ); downloaderWorker.downloadFile( mediaSource, downloadPath, Comlink.proxy((dataState) => { onStateChange(dataState); if (dataState.state === DownloadState.DONE) { addDownloadedMusicToList( setInternalData( musicItem as any, "downloadData", { path: downloadPath, quality: realQuality, }, true, ) as IMusic.IMusicItem, ); } }), ); } else { throw new Error("Invalid Source"); } } catch (e) { console.log(e, "ERROR"); onStateChange({ state: DownloadState.ERROR, msg: e?.message, }); } } function useDownloadStatus(musicItem: IMusic.IMusicItem) { const [downloadStatus, setDownloadStatus] = useState( null, ); useEffect(() => { setDownloadStatus( downloadingProgress.get(getMediaPrimaryKey(musicItem)) || null, ); const updateFn = (mi: IMusic.IMusicItem, stateData: IDownloadStatus) => { if (isSameMedia(mi, musicItem)) { setDownloadStatus(stateData); } }; ee.on(DownloadEvts.DownloadStatusUpdated, updateFn); return () => { ee.off(DownloadEvts.DownloadStatusUpdated, updateFn); }; }, [musicItem]); return downloadStatus; } // 下载状态 function useDownloadState(musicItem: IMusic.IMusicItem) { const musicStatus = useDownloadStatus(musicItem); const downloaded = useDownloaded(musicItem); return ( musicStatus?.state || (downloaded ? DownloadState.DONE : DownloadState.NONE) ); } const Downloader = { setupDownloader, startDownload, useDownloadStatus, useDownloadingMusicList: downloadingMusicStore.useValue, useDownloaded, isDownloaded, useDownloadedMusicList, removeDownloadedMusic, setDownloadingConcurrency, useDownloadState, }; export default Downloader; ================================================ FILE: src/renderer/core/downloader/store.ts ================================================ import Store from "@/common/store"; const downloadingMusicStore = new Store>([]); export { downloadingMusicStore }; ================================================ FILE: src/renderer/core/link-lyric/index.ts ================================================ import { getInternalData, getMediaPrimaryKey, setInternalData, } from "@/common/media-util"; import { LRUCache } from "lru-cache"; import musicSheetDB from "../db/music-sheet-db"; import PluginManager from "@shared/plugin-manager/renderer"; const linkLyricCache = new LRUCache({ max: 500, allowStale: false, }); const linkLyricKey = "associatedLrc"; export async function linkLyric( from: IMusic.IMusicItem, to: IMusic.IMusicItem, ) { // 如果歌曲已经入库,更新数据库中的meta信息 const filteredMusicItem: IMedia.IUnique = { platform: to.platform, id: to.id, }; for (const toPk of PluginManager.getPluginPrimaryKey(to)) { filteredMusicItem[toPk] = to[toPk]; } const fromPk = getMediaPrimaryKey(from); linkLyricCache.set(fromPk, filteredMusicItem); try { await musicSheetDB.transaction("rw", musicSheetDB.musicStore, async () => { const musicItem = await musicSheetDB.musicStore.get([ from.platform, from.id, ]); if (musicItem) { await musicSheetDB.musicStore.put( setInternalData(musicItem, linkLyricKey, filteredMusicItem, true), ); } }); } catch (e) { console.log(e); } } export async function unlinkLyric(musicItem: IMusic.IMusicItem) { const pk = getMediaPrimaryKey(musicItem); const cachedItem = linkLyricCache.get(pk); if (cachedItem) { linkLyricCache.delete(pk); } try { await musicSheetDB.transaction("rw", musicSheetDB.musicStore, async () => { const dbMusicItem = await musicSheetDB.musicStore.get([ musicItem.platform, musicItem.id, ]); if (dbMusicItem) { await musicSheetDB.musicStore.put( setInternalData(dbMusicItem, linkLyricKey, undefined, true), ); } }); } catch {} } export async function getLinkedLyric(musicItem: IMusic.IMusicItem) { const pk = getMediaPrimaryKey(musicItem); const cachedItem = linkLyricCache.get(pk); if (cachedItem) { return cachedItem as IMusic.IMusicItem; } try { const result = await musicSheetDB.transaction( "r", musicSheetDB.musicStore, async () => { const dbMusicItem = await musicSheetDB.musicStore.get([ musicItem.platform, musicItem.id, ]); if (dbMusicItem) { const linkedLyric = getInternalData(dbMusicItem, linkLyricKey); return linkedLyric; } }, ); if (result) { linkLyricCache.set(pk, result); return result; } } catch (e) { console.log(e); } return null; } ================================================ FILE: src/renderer/core/local-music/index.ts ================================================ import localMusicListStore from "./store"; import { getUserPreferenceIDB } from "@/renderer/utils/user-perference"; import * as Comlink from "comlink"; import musicSheetDB from "../db/music-sheet-db"; import { getGlobalContext } from "@/shared/global-context/renderer"; type ProxyMarkedFunction void> = T & Comlink.ProxyMarked; type IMusicItemWithLocalPath = IMusic.IMusicItem & { $$localPath: string }; interface ILocalFileWatcherWorker { setupWatcher: (initPaths?: string[]) => Promise; changeWatchPath: (addPaths?: string[], rmPaths?: string[]) => Promise; onAdd: ( cb: ProxyMarkedFunction< (musicItems: Array) => Promise > ) => void; onRemove: ( cb: ProxyMarkedFunction<(filePaths: string[]) => Promise> ) => void; } let localFileWatcherWorker: ILocalFileWatcherWorker; function isSubDir(parent: string, target: string) { const relative = window.path.relative(parent, target); return ( relative && !relative.startsWith("..") && !window.path.isAbsolute(relative) ); } async function setupLocalMusic() { try { const localWatchDir = (await getUserPreferenceIDB("localWatchDirChecked")) ?? []; const localFileWatcherWorkerPath = getGlobalContext().workersPath.localFileWatcher; if (localFileWatcherWorkerPath) { const worker = new Worker(localFileWatcherWorkerPath); localFileWatcherWorker = Comlink.wrap(worker); await localFileWatcherWorker.setupWatcher(localWatchDir); } const allMusic = await musicSheetDB.localMusicStore.toArray(); localMusicListStore.setValue(allMusic); localFileWatcherWorker.onAdd( Comlink.proxy(async (musicItems: IMusicItemWithLocalPath[]) => { await musicSheetDB.transaction( "rw", musicSheetDB.localMusicStore, async () => { await musicSheetDB.localMusicStore.bulkPut(musicItems); const allMusic = await musicSheetDB.localMusicStore.toArray(); localMusicListStore.setValue(allMusic); }, ); }), ); localFileWatcherWorker.onRemove( Comlink.proxy(async (filePaths: string[]) => { await musicSheetDB.transaction( "rw", musicSheetDB.localMusicStore, async () => { const tobeDeletedFilePaths = new Set(filePaths); const cachedLocalMusic = localMusicListStore.getValue(); const tobeDeletedPrimaryKeys: any[] = []; const newCachedLocalMusic: IMusicItemWithLocalPath[] = []; cachedLocalMusic.forEach((it) => { if (tobeDeletedFilePaths.has(it.$$localPath)) { tobeDeletedPrimaryKeys.push([it.platform, it.id]); } else { newCachedLocalMusic.push(it); } }); await musicSheetDB.localMusicStore.bulkDelete( tobeDeletedPrimaryKeys, ); localMusicListStore.setValue(newCachedLocalMusic); }, ); }), ); } catch { } } async function changeWatchPath(logs: Map) { // 对所有的要删除的路径 const tobeDeletedPaths: string[] = []; const tobeAddedPaths: string[] = []; logs.forEach((action, dirPath) => { if (action === "delete") { tobeDeletedPaths.push(dirPath); } else { tobeAddedPaths.push(dirPath); } }); // 删除所有子路径的 if (tobeDeletedPaths.length) { await musicSheetDB.transaction( "rw", musicSheetDB.localMusicStore, async () => { const localFiles = localMusicListStore.getValue(); const tobeDeletedItems = localFiles .filter((it) => tobeDeletedPaths.some((deletePath) => isSubDir(deletePath, it.$$localPath), ), ) .map((it) => [it.platform, it.id]); await musicSheetDB.localMusicStore.bulkDelete(tobeDeletedItems); }, ); localMusicListStore.setValue(await musicSheetDB.localMusicStore.toArray()); } // 通知 localFileWatcherWorker.changeWatchPath(tobeAddedPaths, tobeDeletedPaths); } // async function syncLocalMusic() { // ipcRendererSend("sync-local-music"); // } export default { setupLocalMusic, // syncLocalMusic, changeWatchPath, }; ================================================ FILE: src/renderer/core/local-music/store.ts ================================================ import Store from "@/common/store"; const localMusicListStore = new Store>([]); export default localMusicListStore; ================================================ FILE: src/renderer/core/music-sheet/backend/index.ts ================================================ /** * 这里不应该写任何和UI有关的逻辑,只是简单的数据库操作 * * 除了frontend文件夹外,其他任何地方不应该直接调用此处定义的函数 */ import { localPluginName, musicRefSymbol, sortIndexSymbol, timeStampSymbol } from "@/common/constant"; import { nanoid } from "nanoid"; import musicSheetDB from "../../db/music-sheet-db"; import { produce } from "immer"; import defaultSheet from "../common/default-sheet"; import { getMediaPrimaryKey, isSameMedia } from "@/common/media-util"; import { getUserPreferenceIDB, setUserPreferenceIDB } from "@/renderer/utils/user-perference"; /******************** 内存缓存 ***********************/ // 默认歌单,快速判定是否在列表中 const favoriteMusicListIds = new Set(); // 全部的歌单列表(无详情,只有ID) let musicSheets: IMusic.IDBMusicSheetItem[] = []; // 星标的歌单信息 let starredMusicSheets: IMedia.IMediaBase[] = []; /******************** 方法 ***********************/ /** * 获取全部音乐信息 * @returns */ export function getAllSheets() { return musicSheets; } export function getAllStarredSheets() { return starredMusicSheets; } /** * * 查询所有歌单信息(无详情) * * @returns 全部歌单信息 */ export async function queryAllSheets() { try { // 读取全部歌单 const allSheets = await musicSheetDB.sheets.toArray(); const defaultSheetIndex = allSheets.findIndex(item => item.id === defaultSheet.id); if (allSheets.length === 0 || defaultSheetIndex === -1) { await musicSheetDB.transaction( "readwrite", musicSheetDB.sheets, async () => { musicSheetDB.sheets.put(defaultSheet); }, ); musicSheets = [defaultSheet, ...allSheets]; } else { const dbDefaultSheet = allSheets.find( (item) => item.id === defaultSheet.id, ); dbDefaultSheet.musicList.forEach((mi) => { favoriteMusicListIds.add(getMediaPrimaryKey(mi)); }); musicSheets = allSheets; if (defaultSheetIndex !== 0) { allSheets.splice(defaultSheetIndex, 1); allSheets.unshift(dbDefaultSheet); } } // 收藏歌单 return musicSheets; } catch (e) { console.log(e); return musicSheets; } } /** * 查询所有收藏歌单 * @returns 收藏歌单信息 */ export async function queryAllStarredSheets() { try { starredMusicSheets = (await getUserPreferenceIDB("starredMusicSheets")) || []; return starredMusicSheets; } catch { return []; } } /** * 新建歌单 * @param sheetName 歌单名 * @returns 新建的歌单信息 */ export async function addSheet(sheetName: string) { const id = nanoid(); const newSheet: IMusic.IMusicSheetItem = { id, title: sheetName, createAt: Date.now(), platform: localPluginName, musicList: [], $$sortIndex: musicSheets[musicSheets.length - 1].$$sortIndex + 1, }; try { await musicSheetDB.transaction( "readwrite", musicSheetDB.sheets, async () => { musicSheetDB.sheets.put(newSheet); }, ); musicSheets = [...musicSheets, newSheet]; return newSheet; } catch { throw new Error("新建失败"); } } /** * 更新歌单信息 * @param sheetId 歌单ID * @param newData 最新的歌单信息 * @returns */ export async function updateSheet( sheetId: string, newData: Partial, ) { try { if (!newData) { return; } await musicSheetDB.transaction( "readwrite", musicSheetDB.sheets, async () => { musicSheetDB.sheets.update(sheetId, newData); }, ); musicSheets = produce(musicSheets, (draft) => { const currentIndex = draft.findIndex((_) => _.id === sheetId); if (currentIndex === -1) { draft.push(newData as IMusic.IDBMusicSheetItem); } else { draft[currentIndex] = { ...draft[currentIndex], ...newData, }; } }); } catch (e) { // 更新歌单信息失败 console.log(e); } } /** * 移除歌单 * @param sheetId 歌单ID * @returns 删除后的ID */ export async function removeSheet(sheetId: string) { try { if (sheetId === defaultSheet.id) { // 默认歌单不可删除 return; } await musicSheetDB.transaction( "readwrite", musicSheetDB.sheets, musicSheetDB.musicStore, async () => { const targetSheet = musicSheets.find((item) => item.id === sheetId); await removeMusicFromSheet( targetSheet.musicList ?? ([] as any), sheetId, ); musicSheetDB.sheets.delete(sheetId); }, ); musicSheets = musicSheets.filter((it) => it.id !== sheetId); return musicSheets; } catch (e) { console.log(e); } } /** * 清空所有音乐 * @param sheetId 歌单ID * @returns 删除后的ID */ export async function clearSheet(sheetId: string) { try { await musicSheetDB.transaction( "readwrite", musicSheetDB.sheets, musicSheetDB.musicStore, async () => { const targetSheet = musicSheets.find((item) => item.id === sheetId); await removeMusicFromSheet( targetSheet.musicList ?? ([] as any), sheetId, ); targetSheet.musicList = []; }, ); return [...musicSheets]; } catch (e) { console.log(e); } } /** * 收藏歌单 * @param sheet */ export async function starMusicSheet(sheet: IMedia.IMediaBase) { const newSheets = [...starredMusicSheets, sheet]; await setUserPreferenceIDB("starredMusicSheets", newSheets); starredMusicSheets = newSheets; } /** * 取消收藏歌单 * @param sheet */ export async function unstarMusicSheet(sheet: IMedia.IMediaBase) { const newSheets = starredMusicSheets.filter( (item) => !isSameMedia(item, sheet), ); await setUserPreferenceIDB("starredMusicSheets", newSheets); starredMusicSheets = newSheets; } /** * 收藏歌单排序 */ export async function setStarredMusicSheets(sheets: IMedia.IMediaBase[]) { await setUserPreferenceIDB("starredMusicSheets", sheets); starredMusicSheets = sheets; } /**************************** 歌曲相关方法 ************************/ /** * 添加歌曲到歌单 * @param musicItems * @param sheetId * @returns */ export async function addMusicToSheet( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], sheetId: string, ) { const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; try { // 当前的列表 const targetSheet = musicSheets.find((item) => item.id === sheetId); if (!targetSheet) { return; } // 筛选出不在列表中的项目 const targetMusicList = targetSheet.musicList; // 要添加到音乐列表中的项目 const validMusicItems = _musicItems.filter( (item) => -1 === targetMusicList.findIndex((mi) => isSameMedia(mi, item)), ); await musicSheetDB.transaction( "rw", musicSheetDB.musicStore, musicSheetDB.sheets, async () => { // 寻找已入库的音乐项目 const allMusic = await musicSheetDB.musicStore.bulkGet( validMusicItems.map((item) => [item.platform, item.id]), ); allMusic.forEach((mi, index) => { if (mi) { mi[musicRefSymbol] += 1; } else { allMusic[index] = { ...validMusicItems[index], [musicRefSymbol]: 1, }; } }); await musicSheetDB.musicStore.bulkPut(allMusic); const timeStamp = Date.now(); await musicSheetDB.sheets .where("id") .equals(sheetId) .modify((obj) => { obj.artwork = validMusicItems[validMusicItems.length - 1]?.artwork ?? obj.artwork; obj.musicList = [ ...(obj.musicList ?? []), ...validMusicItems.map((item, index) => ({ platform: item.platform, id: item.id, [sortIndexSymbol]: index, [timeStampSymbol]: timeStamp, })), ]; targetSheet.artwork = obj.artwork; targetSheet.musicList = obj.musicList; musicSheets = [...musicSheets]; }); }, ); if (sheetId === defaultSheet.id) { _musicItems.forEach((mi) => { favoriteMusicListIds.add(getMediaPrimaryKey(mi)); }); } return musicSheets; } catch { console.log("error!!"); } } /** * 从歌单内移除歌曲 * @param musicItems 要移除的歌曲 * @param sheetId 歌单ID * @returns */ export async function removeMusicFromSheet( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], sheetId: string, ) { const targetSheet = musicSheets.find((item) => item.id === sheetId); if (!targetSheet) { return; } // 重新组装 const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems]; const targetMusicList = targetSheet.musicList ?? []; const toBeRemovedMusic: IMedia.IMediaBase[] = []; const restMusic: IMedia.IMediaBase[] = []; for (const mi of targetMusicList) { // 用map会更快吧 if (_musicItems.findIndex((item) => isSameMedia(mi, item)) === -1) { // 剩余的音乐 restMusic.push(mi); } else { // 将要删除的音乐 toBeRemovedMusic.push(mi); } } try { await musicSheetDB.transaction( "rw", musicSheetDB.sheets, musicSheetDB.musicStore, async () => { // 寻找引用 const toBeRemovedMusicDetail = await musicSheetDB.musicStore.bulkGet( toBeRemovedMusic.map((item) => [item.platform, item.id]), ); // 如果引用计数为0,进入删除队列 const needDelete: any[] = []; // 如果不为0,进入更新队列 const needUpdate: any[] = []; toBeRemovedMusicDetail.forEach((musicItem) => { if (!musicItem) { return; } musicItem[musicRefSymbol]--; if (musicItem[musicRefSymbol] === 0) { needDelete.push([musicItem.platform, musicItem.id]); } else { needUpdate.push(musicItem); } }); await musicSheetDB.musicStore.bulkDelete(needDelete); await musicSheetDB.musicStore.bulkPut(needUpdate); // 当前的最后一首歌 const lastMusic = restMusic[restMusic.length - 1]; // 更新当前歌单的封面 let newArtwork: string; if (lastMusic) { newArtwork = ( await musicSheetDB.musicStore.get([ lastMusic.platform, lastMusic.id, ]) ).artwork; } await musicSheetDB.sheets .where("id") .equals(sheetId) .modify((obj) => { obj.artwork = newArtwork; obj.musicList = restMusic; // 修改 MusicSheets targetSheet.artwork = newArtwork; targetSheet.musicList = obj.musicList; musicSheets = [...musicSheets]; }); }, ); if (sheetId === defaultSheet.id) { // 从默认歌单里删除 toBeRemovedMusic.forEach((mi) => { favoriteMusicListIds.delete(getMediaPrimaryKey(mi)); }); } } catch (e) { console.log(e); throw e; } } /** 获取歌单内的歌曲详细信息 */ export async function getSheetItemDetail( sheetId: string, ): Promise { // 取太多歌曲时会卡顿, 1000首歌大约100ms const targetSheet = musicSheets.find((item) => item.id === sheetId); if (!targetSheet) { return null; } const tmpResult = []; const musicList = targetSheet.musicList ?? []; // 一组800个 const groupSize = 800; const groupNum = Math.ceil(musicList.length / groupSize); for (let i = 0; i < groupNum; ++i) { const sliceResult = await musicSheetDB.transaction( "readonly", musicSheetDB.musicStore, async () => { return await musicSheetDB.musicStore.bulkGet( musicList .slice(i * groupSize, (i + 1) * groupSize) .map((item) => [item.platform, item.id]), ); }, ); tmpResult.push(...(sliceResult ?? [])); } return { ...targetSheet, musicList: tmpResult, } as IMusic.IMusicSheetItem; } /** * 某首歌是否被标记为喜欢 * @param musicItem * @returns */ export function isFavoriteMusic(musicItem: IMusic.IMusicItem) { return favoriteMusicListIds.has(getMediaPrimaryKey(musicItem)); } /** 导出所有歌单信息 */ export async function exportAllSheetDetails() { return await musicSheetDB.transaction( "readonly", musicSheetDB.musicStore, async () => { const allSheets = musicSheets; if (!allSheets) { return []; } const musicLists = await Promise.all( allSheets.map((sheet) => musicSheetDB.musicStore.bulkGet( (sheet.musicList ?? []).map((item) => [item.platform, item.id]), ), ), ); const allSheetDetails = produce(allSheets, (draft) => { draft.forEach((sheet, index) => { sheet.musicList = musicLists[index]; }); }); return allSheetDetails; }, ); } ================================================ FILE: src/renderer/core/music-sheet/common/default-sheet.ts ================================================ import { localPluginName } from "@/common/constant"; import { i18n } from "@/shared/i18n/renderer"; export default { id: "favorite", title: i18n.t("media.default_favorite_sheet_name"), platform: localPluginName, musicList: [], $$sortIndex: -1, $sortIndex: -1, }; ================================================ FILE: src/renderer/core/music-sheet/frontend/index.old.ts ================================================ import Store from "@/common/store"; import * as backend from "../backend"; import defaultSheet from "../common/default-sheet"; import { useEffect, useRef, useState } from "react"; import { RequestStateCode, localPluginName } from "@/common/constant"; import { toMediaBase } from "@/common/media-util"; const musicSheetsStore = new Store([]); const starredSheetsStore = new Store([]); export const useAllSheets = musicSheetsStore.useValue; export const useAllStarredSheets = starredSheetsStore.useValue; export const getAllSheets = musicSheetsStore.getValue; /** 更新默认歌单变化 */ const refreshFavCbs = new Set<() => void>(); function refreshFavoriteState() { refreshFavCbs.forEach((cb) => cb?.()); } /** * 初始化 */ export async function setupMusicSheets() { const [musicSheets, starredSheets] = await Promise.all([ backend.queryAllSheets(), backend.queryAllStarredSheets(), ]); musicSheetsStore.setValue(musicSheets); starredSheetsStore.setValue(starredSheets); } /** * 新建歌单 * @param sheetName 歌单名 * @returns 新建的歌单信息 */ export async function addSheet(sheetName: string) { try { const newSheetDetail = await backend.addSheet(sheetName); musicSheetsStore.setValue(backend.getAllSheets()); return newSheetDetail; } catch {} } /** * 更新歌单信息 * @param sheetId 歌单ID * @param newData 最新的歌单信息 * @returns */ export async function updateSheet( sheetId: string, newData: Partial, ) { try { await backend.updateSheet(sheetId, newData); musicSheetsStore.setValue(backend.getAllSheets()); } catch {} } /** * 更新歌单中的歌曲顺序 * @param sheetId * @param musicList */ export async function updateSheetMusicOrder( sheetId: string, musicList: IMusic.IMusicItem[], ) { try { const targetSheet = musicSheetsStore .getValue() .find((it) => it.id === sheetId); updateSheetDetail({ ...targetSheet, musicList, }); await backend.updateSheet(sheetId, { musicList: musicList.map(toMediaBase) as any, }); musicSheetsStore.setValue(backend.getAllSheets()); } catch {} } /** * 移除歌单 * @param sheetId 歌单ID * @returns 删除后的ID */ export async function removeSheet(sheetId: string) { try { await backend.removeSheet(sheetId); musicSheetsStore.setValue(backend.getAllSheets()); } catch {} } /** * 清空所有音乐 * @param sheetId 歌单ID * @returns 删除后的ID */ export async function clearSheet(sheetId: string) { try { await backend.clearSheet(sheetId); musicSheetsStore.setValue(backend.getAllSheets()); refetchSheetDetail(sheetId); } catch {} } /** * 收藏歌单 * @param sheet */ export async function starMusicSheet(sheet: IMedia.IMediaBase) { await backend.starMusicSheet(sheet); starredSheetsStore.setValue(backend.getAllStarredSheets()); } /** * 取消收藏歌单 * @param sheet */ export async function unstarMusicSheet(sheet: IMedia.IMediaBase) { await backend.unstarMusicSheet(sheet); starredSheetsStore.setValue(backend.getAllStarredSheets()); } /** * 收藏歌单排序 */ export async function setStarredMusicSheets(sheets: IMedia.IMediaBase[]) { await backend.setStarredMusicSheets(sheets); starredSheetsStore.setValue(backend.getAllStarredSheets()); } /**************************** 歌曲相关方法 ************************/ /** * 添加歌曲到歌单 * @param musicItems * @param sheetId * @returns */ export async function addMusicToSheet( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], sheetId: string, ) { const start = Date.now(); await backend.addMusicToSheet(musicItems, sheetId); console.log("添加音乐", Date.now() - start, "ms"); musicSheetsStore.setValue(backend.getAllSheets()); if (sheetId === defaultSheet.id) { // 更新默认列表的状态 refreshFavoriteState(); } refetchSheetDetail(sheetId); } /** 添加到默认歌单 */ export async function addMusicToFavorite( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], ) { return addMusicToSheet(musicItems, defaultSheet.id); } /** * 从歌单内移除歌曲 * @param musicItems 要移除的歌曲 * @param sheetId 歌单ID * @returns */ export async function removeMusicFromSheet( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], sheetId: string, ) { const start = Date.now(); await backend.removeMusicFromSheet(musicItems, sheetId); console.log("删除音乐", Date.now() - start, "ms"); musicSheetsStore.setValue(backend.getAllSheets()); if (sheetId === defaultSheet.id) { // 更新默认列表的状态 refreshFavoriteState(); } refetchSheetDetail(sheetId); } /** 从默认歌单中移除 */ export async function removeMusicFromFavorite( musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], ) { return removeMusicFromSheet(musicItems, defaultSheet.id); } /** 是否是我喜欢的歌单 */ export function isFavoriteMusic(musicItem: IMusic.IMusicItem) { return backend.isFavoriteMusic(musicItem); } /** hook 某首歌曲是否被标记成喜欢 */ export function useMusicIsFavorite(musicItem: IMusic.IMusicItem) { const [isFav, setIsFav] = useState(backend.isFavoriteMusic(musicItem)); useEffect(() => { const cb = () => { setIsFav(backend.isFavoriteMusic(musicItem)); }; cb(); refreshFavCbs.add(cb); return () => { refreshFavCbs.delete(cb); }; }, [musicItem]); return isFav; } const updateSheetDetailCallbacks: Map< string, Set<(newSheet: IMusic.IMusicSheetItem) => void> > = new Map(); function updateSheetDetail(newSheet: IMusic.IMusicSheetItem) { updateSheetDetailCallbacks.get(newSheet?.id)?.forEach((cb) => cb?.(newSheet)); } /** * 重新取歌单状态 * @param sheetId */ async function refetchSheetDetail(sheetId: string) { let sheetDetail = await backend.getSheetItemDetail(sheetId); if (!sheetDetail) { // 可能已经被删除了 sheetDetail = { id: sheetId, title: "已删除歌单", artist: "未知作者", platform: localPluginName, }; } updateSheetDetail(sheetDetail); } /** * 监听当前某个歌单 * @param sheetId 歌单ID * @param initQuery 是否重新查询 */ export function useMusicSheet(sheetId: string) { const [pendingState, setPendingState] = useState( RequestStateCode.PENDING_FIRST_PAGE, ); const [sheetItem, setSheetItem] = useState( null, ); // 实时的sheetId const realTimeSheetIdRef = useRef(sheetId); realTimeSheetIdRef.current = sheetId; const pendingStateRef = useRef(pendingState); pendingStateRef.current = pendingState; useEffect(() => { const updateSheet = async (newSheet: IMusic.IMusicSheetItem) => { // 如果更新的是当前歌单,则设置 if (realTimeSheetIdRef.current === newSheet.id) { setSheetItem(newSheet); setPendingState(RequestStateCode.FINISHED); } }; const cbs = updateSheetDetailCallbacks.get(sheetId) ?? new Set(); cbs.add(updateSheet); updateSheetDetailCallbacks.set(sheetId, cbs); const targetSheet = musicSheetsStore .getValue() .find((item) => item.id === sheetId); if (targetSheet) { setSheetItem({ ...targetSheet, musicList: [], }); } setPendingState(RequestStateCode.PENDING_FIRST_PAGE); refetchSheetDetail(sheetId); return () => { cbs?.delete(updateSheet); }; }, [sheetId]); return [sheetItem, pendingState] as const; } /** * 监听当前某个歌单 * @param sheetId 歌单ID * @param initQuery 是否重新查询 */ // export function useMusicSheet(sheetId: string) { // const [pendingState, setPendingState] = useState( // RequestStateCode.PENDING_FIRST_PAGE // ); // const [sheetItem, setSheetItem] = useState( // null // ); // // 实时的sheetId // const realTimeSheetIdRef = useRef(sheetId); // realTimeSheetIdRef.current = sheetId; // const pendingStateRef = useRef(pendingState); // pendingStateRef.current = pendingState; // useEffect(() => { // const updateSheet = async () => { // const start = Date.now(); // const sheetDetail = await backend.getSheetItemDetail(sheetId); // console.log("歌单详情", Date.now() - start, "ms"); // if (realTimeSheetIdRef.current === sheetId) { // console.log("歌单详情", sheetId); // setSheetItem(sheetDetail); // setPendingState(RequestStateCode.FINISHED); // } // }; // const updateSheetCallback = async () => { // if (!(pendingStateRef.current & RequestStateCode.LOADING)) { // setPendingState(RequestStateCode.PENDING_REST_PAGE); // await updateSheet(); // } // }; // const cbs = updateSheetCbs.get(sheetId) ?? new Set(); // cbs.add(updateSheetCallback); // updateSheetCbs.set(sheetId, cbs); // const targetSheet = musicSheetsStore // .getValue() // .find((item) => item.id === sheetId); // if (targetSheet) { // setSheetItem({ // ...targetSheet, // musicList: [], // }); // } // setPendingState(RequestStateCode.PENDING_FIRST_PAGE); // updateSheet(); // return () => { // cbs?.delete(updateSheetCallback); // }; // }, [sheetId]); // return [sheetItem, pendingState] as const; // } export async function exportAllSheetDetails() { return await backend.exportAllSheetDetails(); } ================================================ FILE: src/renderer/core/music-sheet/frontend/index.ts ================================================ export * from "./index.old"; ================================================ FILE: src/renderer/core/music-sheet/index.ts ================================================ import * as frontend from "./frontend"; import defaultSheet from "./common/default-sheet"; const MusicSheet = { // ...sheetsMethod, defaultSheet, frontend, }; export default MusicSheet; export { defaultSheet }; ================================================ FILE: src/renderer/core/recently-playlist/index.ts ================================================ import { isSameMedia } from "@/common/media-util"; import Store from "@/common/store"; import { getUserPreferenceIDB, setUserPreferenceIDB, } from "@/renderer/utils/user-perference"; import { Immer } from "immer"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; const recentlyPlayListStore = new Store([]); const immer = new Immer({ autoFreeze: false, }); const HARD_LIMIT = 500; async function fetchRecentlyPlaylist() { return (await getUserPreferenceIDB("recentlyPlayList")) || []; } async function setRecentlyPlaylist(musicItems: IMusic.IMusicItem[]) { recentlyPlayListStore.setValue(musicItems); return await setUserPreferenceIDB("recentlyPlayList", musicItems); } export async function setupRecentlyPlaylist() { const playList = (await fetchRecentlyPlaylist()).filter(it => !!it); recentlyPlayListStore.setValue(playList); } export async function addToRecentlyPlaylist(musicItem: IMusic.IMusicItem) { if (!musicItem || !musicItem.id || !musicItem.platform) { return; } const playList = recentlyPlayListStore.getValue(); const existId = playList.findIndex((it) => isSameMedia(musicItem, it)); let newPlayList = playList; if (existId !== -1) { newPlayList = immer.produce(playList, (draft) => { draft.splice(existId, 1); }); } newPlayList = [musicItem].concat(newPlayList).slice(0, HARD_LIMIT); setRecentlyPlaylist(newPlayList); } export async function removeRecentlyPlayList(musicItem: IMusic.IMusicItem) { const playList = recentlyPlayListStore.getValue(); const existId = playList.findIndex((it) => isSameMedia(musicItem, it)); let newPlayList = playList; if (existId !== -1) { newPlayList = immer.produce(playList, (draft) => { draft.splice(existId, 1); }); setRecentlyPlaylist(newPlayList); } } export async function clearRecentlyPlaylist() { setRecentlyPlaylist([]); } export function useRecentlyPlaylistSheet() { const recentlyPlayList = recentlyPlayListStore.useValue(); const { t } = useTranslation(); const musicSheet: IMusic.IMusicSheetItem = useMemo(() => { return { id: "recently-play", title: t("side_bar.recently_play"), platform: "recently-play", playCount: recentlyPlayList?.length || 0, artwork: recentlyPlayList?.[0]?.artwork, musicList: recentlyPlayList || [], }; }, [recentlyPlayList, t]); return musicSheet; } ================================================ FILE: src/renderer/core/track-player/controller/audio-controller.ts ================================================ /** * 播放音乐 */ import { encodeUrlHeaders } from "@/common/normalize-util"; import albumImg from "@/assets/imgs/album-cover.jpg"; import getUrlExt from "@/renderer/utils/get-url-ext"; import Hls, { Events as HlsEvents, HlsConfig } from "hls.js"; import { isSameMedia } from "@/common/media-util"; import { PlayerState } from "@/common/constant"; import ServiceManager from "@shared/service-manager/renderer"; import ControllerBase from "@renderer/core/track-player/controller/controller-base"; import { ErrorReason } from "@renderer/core/track-player/enum"; import Dexie from "dexie"; import voidCallback from "@/common/void-callback"; import { IAudioController } from "@/types/audio-controller"; import Promise = Dexie.Promise; class AudioController extends ControllerBase implements IAudioController { private audio: HTMLAudioElement; private hls: Hls; private _playerState: PlayerState = PlayerState.None; get playerState() { return this._playerState; } set playerState(value: PlayerState) { if (this._playerState !== value) { this.onPlayerStateChanged?.(value); } this._playerState = value; } public musicItem: IMusic.IMusicItem | null = null; get hasSource() { return !!this.audio.src; } constructor() { super(); this.audio = new Audio(); this.audio.preload = "auto"; this.audio.controls = false; ////// events this.audio.onplaying = () => { this.playerState = PlayerState.Playing; navigator.mediaSession.playbackState = "playing"; }; this.audio.onpause = () => { this.playerState = PlayerState.Paused; navigator.mediaSession.playbackState = "paused"; }; this.audio.onerror = (event) => { this.playerState = PlayerState.Paused; navigator.mediaSession.playbackState = "paused"; this.onError?.(ErrorReason.EmptyResource, event as any); }; this.audio.ontimeupdate = () => { this.onProgressUpdate?.({ currentTime: this.audio.currentTime, duration: this.audio.duration, // 缓冲中是Infinity }); }; // this.audio.onseeking = () => { // this.playerState = PlayerState.Buffering; // } // // this.audio.onseeked = () => { // this.playerState = PlayerState.Playing; // } this.audio.onended = () => { this.playerState = PlayerState.Paused; this.onEnded?.(); }; this.audio.onvolumechange = () => { this.onVolumeChange?.(this.audio.volume); }; this.audio.onratechange = () => { this.onSpeedChange?.(this.audio.playbackRate); }; // @ts-ignore isDev window.ad = this.audio; } private initHls(config?: Partial) { if (!this.hls) { this.hls = new Hls(config); this.hls.attachMedia(this.audio); this.hls.on(HlsEvents.ERROR, (evt, error) => { this.onError(ErrorReason.EmptyResource, error); }); } } private destroyHls() { if (this.hls) { this.hls.detachMedia(); this.hls.off(HlsEvents.ERROR); this.hls.destroy(); this.hls = null; } } destroy(): void { this.destroyHls(); this.reset(); } pause(): void { if (this.hasSource) { this.audio.pause(); } } play(): void { if (this.hasSource) { this.audio.play().catch(voidCallback); } } reset(): void { this.playerState = PlayerState.None; this.audio.src = ""; this.audio.removeAttribute("src"); navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = "none"; } seekTo(seconds: number): void { if (this.hasSource && isFinite(seconds)) { const duration = this.audio.duration; this.audio.currentTime = Math.min( seconds, isNaN(duration) ? Infinity : duration, ); } } setLoop(isLoop: boolean): void { this.audio.loop = isLoop; } setSinkId(deviceId: string): Promise { return (this.audio as any).setSinkId(deviceId); } setSpeed(speed: number): void { this.audio.defaultPlaybackRate = speed; this.audio.playbackRate = speed; } prepareTrack(musicItem: IMusic.IMusicItem) { this.musicItem = { ...musicItem }; // 1. update metadata navigator.mediaSession.metadata = new MediaMetadata({ title: musicItem.title, artist: musicItem.artist, album: musicItem.album, artwork: [ { src: musicItem.artwork ?? albumImg, }, ], }); // 2. reset track this.playerState = PlayerState.None; this.audio.src = ""; this.audio.removeAttribute("src"); navigator.mediaSession.playbackState = "none"; } setTrackSource(trackSource: IMusic.IMusicSource, musicItem: IMusic.IMusicItem): void { this.musicItem = { ...musicItem }; // 1. update metadata navigator.mediaSession.metadata = new MediaMetadata({ title: musicItem.title, artist: musicItem.artist, album: musicItem.album, artwork: [ { src: musicItem.artwork ?? albumImg, }, ], }); // 2. convert url and headers let url = trackSource.url; const urlObj = new URL(trackSource.url); let headers: Record | null = null; // 2.1 convert user agent if (trackSource.headers || trackSource.userAgent) { headers = { ...(trackSource.headers ?? {}) }; if (trackSource.userAgent) { headers["user-agent"] = trackSource.userAgent; } } // 2.2 convert auth header if (urlObj.username && urlObj.password) { const authHeader = `Basic ${btoa( `${decodeURIComponent(urlObj.username)}:${decodeURIComponent( urlObj.password, )}`, )}`; urlObj.username = ""; urlObj.password = ""; headers = { ...(headers || {}), Authorization: authHeader, }; url = urlObj.toString(); } // 2.3 hack url with headers if (headers) { const forwardedUrl = ServiceManager.RequestForwarderService.forwardRequest(url, "GET", headers); if (forwardedUrl) { url = forwardedUrl; headers = null; } else if (!headers["Authorization"]) { url = encodeUrlHeaders(url, headers); headers = null; } } if (!url) { this.onError(ErrorReason.EmptyResource, new Error("url is empty")); return; } // 3. set real source if (getUrlExt(trackSource.url) === ".m3u8") { if (Hls.isSupported()) { this.initHls(); this.hls.loadSource(url); } else { this.onError(ErrorReason.UnsupportedResource); return; } } else if (headers) { fetch(url, { method: "GET", headers: { ...trackSource.headers, }, }) .then(async (res) => { const blob = await res.blob(); if (isSameMedia(this.musicItem, musicItem)) { this.audio.src = URL.createObjectURL(blob); } }); } else { this.audio.src = url; } } setVolume(volume: number): void { this.audio.volume = volume; } } export default AudioController; ================================================ FILE: src/renderer/core/track-player/controller/controller-base.ts ================================================ import { CurrentTime, ErrorReason } from "@renderer/core/track-player/enum"; import { PlayerState } from "@/common/constant"; export default class ControllerBase { public onPlayerStateChanged?: (state: PlayerState) => void; // 进度更新 public onProgressUpdate?: (progress: CurrentTime) => void; // 出错 public onError?: (type: ErrorReason, error?: any) => void; // 播放结束 public onEnded?: () => void; // 音量改变 public onVolumeChange?: (volume: number) => void; // 速度改变 public onSpeedChange?: (speed: number) => void; } ================================================ FILE: src/renderer/core/track-player/enum.ts ================================================ import LyricParser, { IParsedLrcItem } from "@/renderer/utils/lyric-parser"; /** 错误信息 */ export enum ErrorReason { /** 音源为空 */ EmptyResource, /** 不支持的类型 */ UnsupportedResource, } export interface ICurrentLyric { parser?: LyricParser; currentLrc?: IParsedLrcItem; } /** 播放器事件 */ export enum PlayerEvents { /** 播放失败 */ Error = "play-back-error", /** 播放状态改变 */ StateChanged = "play-state-changed", /** 进度更新 */ ProgressChanged = "time-updated", /** 音乐改变 */ MusicChanged = "music-changed", /** 音量改变 */ VolumeChanged = "volume-changed", /** 速度改变 */ SpeedChanged = "speed-changed", /** 播放结束 */ // PlayEnd = "play-end", /** modechange */ RepeatModeChanged = "repeat-mode-changed", /** 歌词改变 */ CurrentLyricChanged = "current-lyric-changed", /** 整体歌词改变 */ LyricChanged = "lyric-changed", } /** 当前时间信息 */ export interface CurrentTime { currentTime: number; duration: number; } ================================================ FILE: src/renderer/core/track-player/hooks.ts ================================================ import _trackPlayerStore from "@renderer/core/track-player/store"; const { musicQueueStore, currentMusicStore, currentLyricStore, repeatModeStore, progressStore, playerStateStore, currentVolumeStore, currentSpeedStore, currentQualityStore, } = _trackPlayerStore; export const useCurrentMusic = currentMusicStore.useValue; export const useProgress = progressStore.useValue; export const usePlayerState = playerStateStore.useValue; export const useRepeatMode = repeatModeStore.useValue; export const useMusicQueue = musicQueueStore.useValue; export const useLyric = currentLyricStore.useValue; export const useVolume = currentVolumeStore.useValue; export const useSpeed = currentSpeedStore.useValue; export const useQuality = currentQualityStore.useValue; ================================================ FILE: src/renderer/core/track-player/index.ts ================================================ import { CurrentTime, ICurrentLyric, PlayerEvents } from "./enum"; import shuffle from "lodash.shuffle"; import { addSortProperty, getInternalData, getQualityOrder, isSameMedia, sortByTimestampAndIndex, } from "@/common/media-util"; import { PlayerState, RepeatMode, sortIndexSymbol, timeStampSymbol } from "@/common/constant"; import LyricParser, { IParsedLrcItem } from "@/renderer/utils/lyric-parser"; import { getUserPreference, getUserPreferenceIDB, removeUserPreference, setUserPreference, setUserPreferenceIDB, } from "@/renderer/utils/user-perference"; import AppConfig from "@shared/app-config/renderer"; import { createIndexMap, IIndexMap } from "@/common/index-map"; import _trackPlayerStore from "./store"; import EventEmitter from "eventemitter3"; import { IAudioController } from "@/types/audio-controller"; import AudioController from "@renderer/core/track-player/controller/audio-controller"; import logger from "@shared/logger/renderer"; import voidCallback from "@/common/void-callback"; import { delay } from "@/common/time-util"; import { createUniqueMap } from "@/common/unique-map"; import { getLinkedLyric } from "@renderer/core/link-lyric"; import { fsUtil } from "@shared/utils/renderer"; import PluginManager from "@shared/plugin-manager/renderer"; const { musicQueueStore, currentMusicStore, currentLyricStore, repeatModeStore, progressStore, playerStateStore, currentVolumeStore, currentSpeedStore, currentQualityStore, resetProgress, } = _trackPlayerStore; interface InternalPlayerEvents { [PlayerEvents.RepeatModeChanged]: (repeatMode: RepeatMode) => void; [PlayerEvents.MusicChanged]: (musicItem: IMusic.IMusicItem | null) => void; [PlayerEvents.LyricChanged]: (parser: LyricParser | null) => void; [PlayerEvents.CurrentLyricChanged]: (lyric: IParsedLrcItem | null) => void; [PlayerEvents.Error]: (errorMusicItem: IMusic.IMusicItem | null, reason: any) => void; [PlayerEvents.ProgressChanged]: (progress: CurrentTime) => void; [PlayerEvents.StateChanged]: (state: PlayerState) => void; } interface IPlayOptions { refreshSource?: boolean; restartOnSameMedia?: boolean; seekTo?: number; quality?: IMusic.IQualityKey; } interface ITrackOptions { seekTo?: number; // 自动播放 autoPlay?: boolean; } class TrackPlayer { get currentMusic() { return currentMusicStore.getValue(); } // 只有基础信息 get currentMusicBasicInfo() { const currentMusic = this.currentMusic; if (!currentMusic) { return null; } return { platform: currentMusic.platform, title: currentMusic.title, artist: currentMusic.artist, id: currentMusic.id, album: currentMusic.album, artwork: currentMusic.artwork, } as IMusic.IMusicItem; } get progress() { return progressStore.getValue(); } get playerState() { return playerStateStore.getValue(); } get repeatMode() { return repeatModeStore.getValue(); } get currentQuality() { return currentQualityStore.getValue(); } get speed() { return currentSpeedStore.getValue(); } get volume() { return currentVolumeStore.getValue(); } get lyric() { return currentLyricStore.getValue(); } get musicQueue() { return musicQueueStore.getValue(); } get isEmpty() { return this.musicQueue.length <= 0; } private indexMap: IIndexMap; private currentIndex = -1; private audioController: IAudioController; private ee: EventEmitter; constructor() { this.indexMap = createIndexMap(); this.ee = new EventEmitter(); this.audioController = new AudioController(); } on(event: T, callback: InternalPlayerEvents[T]) { this.ee.on(event, callback as any); } private setupEvents() { this.ee.on(PlayerEvents.Error, async (errorMusicItem) => { // config const needSkip = AppConfig.getConfig("playMusic.playError") === "skip"; this.resetProgress(); if (this.musicQueue.length > 1 && needSkip) { await delay(500); if (this.isCurrentMusic(errorMusicItem)) { this.skipToNext(); } } }); navigator.mediaSession.setActionHandler("nexttrack", () => { this.skipToNext(); }); navigator.mediaSession.setActionHandler("previoustrack", () => { this.skipToPrev(); }); } private createAudioController() { const audioController = new AudioController(); // 播放结束 audioController.onEnded = () => { this.resetProgress(); switch (this.repeatMode) { case RepeatMode.Queue: case RepeatMode.Shuffle: { this.skipToNext(); break; } case RepeatMode.Loop: { this.playIndex(this.currentIndex, { restartOnSameMedia: true, }); } } }; // 进度更新 audioController.onProgressUpdate = ((progress) => { this.setProgress(progress); // 检查歌词 if (this.lyric?.parser) { const lyricItem = this.lyric.parser.getPosition(progress.currentTime); if (this.lyric.currentLrc?.lrc !== lyricItem?.lrc) { this.setCurrentLyric({ parser: this.lyric.parser, currentLrc: lyricItem, }); } } }); audioController.onVolumeChange = (volume) => { currentVolumeStore.setValue(volume); setUserPreference("volume", volume); }; audioController.onSpeedChange = (speed) => { currentSpeedStore.setValue(speed); setUserPreference("speed", speed); }; audioController.onPlayerStateChanged = (state) => { this.setPlayerState(state); }; audioController.onError = async (type, reason) => { this.ee.emit(PlayerEvents.Error, audioController.musicItem, reason); }; this.audioController = audioController; } public async setup() { // 1. Config const [repeatMode, currentMusic, currentProgress, volume, speed, defaultQuality] = [ getUserPreference("repeatMode"), getUserPreference("currentMusic"), getUserPreference("currentProgress"), getUserPreference("volume"), getUserPreference("speed"), getUserPreference("currentQuality") || AppConfig.getConfig("playMusic.defaultQuality"), ]; const playList = ((await getUserPreferenceIDB("playList")) ?? []).filter(it => !!it); addSortProperty(playList); const deviceId = AppConfig.getConfig("playMusic.audioOutputDevice")?.deviceId; // 2. init audio controller this.createAudioController(); this.setupEvents(); // 3. resume state musicQueueStore.setValue(playList); this.indexMap.update(playList); if (repeatMode) { this.setRepeatMode(repeatMode as RepeatMode); } this.setCurrentMusic(currentMusic); this.currentIndex = this.findMusicIndex(currentMusic); if (deviceId) { this.setAudioOutputDevice(deviceId); } if (volume !== null && volume !== undefined) { this.setVolume(volume); } if (speed) { this.setSpeed(speed); } // 4. reload lyric this.fetchCurrentLyric(); // 5. fetch music source this.fetchMediaSource(currentMusic, defaultQuality).then(({ mediaSource, quality }) => { if (this.isCurrentMusic(currentMusic)) { this.setTrack(mediaSource, currentMusic, { seekTo: currentProgress, autoPlay: false, }); this.setCurrentQuality(quality); } }).catch(voidCallback); } // 切换播放模式 public toggleRepeatMode() { let nextRepeatMode = this.repeatMode; switch (nextRepeatMode) { case RepeatMode.Shuffle: nextRepeatMode = RepeatMode.Loop; break; case RepeatMode.Loop: nextRepeatMode = RepeatMode.Queue; break; case RepeatMode.Queue: nextRepeatMode = RepeatMode.Shuffle; break; } this.setRepeatMode(nextRepeatMode); } public async playIndex(index: number, options: IPlayOptions = {}) { const { refreshSource, restartOnSameMedia = true, seekTo, quality: intendedQuality } = options; if (index === -1 && this.musicQueue.length === 0) { // 播放列表为空 return; } // 1. normalize index index = (index + this.musicQueue.length) % this.musicQueue.length; // 2. same media if (this.currentIndex === index && this.isCurrentMusic(this.musicQueue[index]) && !refreshSource) { if (restartOnSameMedia) { this.seekTo(0); } this.audioController.play(); return; } // update music const nextMusicItem = this.musicQueue[index]; this.setCurrentMusic(nextMusicItem); this.currentIndex = index; this.setPlayerState(PlayerState.Buffering); this.audioController.prepareTrack?.(nextMusicItem); try { const { mediaSource, quality } = await this.fetchMediaSource(nextMusicItem, intendedQuality); if (!mediaSource.url) { throw new Error("mediaSource.url is empty"); } if (!this.isCurrentMusic(nextMusicItem)) { // should be aborted return; } this.setCurrentQuality(quality); this.setTrack(mediaSource, nextMusicItem, { seekTo, autoPlay: true, }); // extra information const musicInfo = await PluginManager.callPluginDelegateMethod( { platform: nextMusicItem.platform, }, "getMusicInfo", nextMusicItem, ).catch(voidCallback); if (!(musicInfo && this.isCurrentMusic(nextMusicItem) && typeof musicInfo === "object")) { return; } this.setCurrentMusic({ ...nextMusicItem, ...musicInfo, platform: nextMusicItem.platform, id: nextMusicItem.id, }); } catch (e) { // 播放失败 this.setCurrentQuality(AppConfig.getConfig("playMusic.defaultQuality")); this.audioController.reset(); this.ee.emit(PlayerEvents.Error, nextMusicItem, e); } } public async playMusic(musicItem: IMusic.IMusicItem, options: IPlayOptions = {}) { if (!musicItem) { return; } const queueIndex = this.findMusicIndex(musicItem); if (queueIndex === -1) { // TODO: 用add代替 const newQueue = [ ...this.musicQueue, { ...musicItem, [timeStampSymbol]: Date.now(), [sortIndexSymbol]: 0, }, ]; this.setMusicQueue(newQueue); await this.playIndex(newQueue.length - 1, options); } else { await this.playIndex(queueIndex, options); } } public async playMusicWithReplaceQueue(musicList: IMusic.IMusicItem[], musicItem?: IMusic.IMusicItem) { if (!musicList.length && !musicItem) { return; } addSortProperty(musicList); if (this.repeatMode === RepeatMode.Shuffle) { musicList = shuffle(musicList); } musicItem = musicItem ?? musicList[0]; this.setMusicQueue(musicList); await this.playMusic(musicItem); } public skipToPrev() { if (this.isEmpty) { this.setCurrentMusic(null); this.currentIndex = -1; return; } this.playIndex(this.currentIndex - 1); } public skipToNext() { if (this.isEmpty) { this.setCurrentMusic(null); this.currentIndex = -1; return; } this.playIndex(this.currentIndex + 1); } // 重置播放状态 public reset() { this.audioController.reset(); this.setMusicQueue([]); this.setCurrentMusic(null); this.resetProgress(); this.currentIndex = -1; } public seekTo(seconds: number) { this.audioController.seekTo(seconds); } public pause() { this.audioController.pause(); if (this.playerState !== this.audioController.playerState) { this.setPlayerState(this.audioController.playerState); } } public resume() { this.audioController.play(); if (this.playerState !== this.audioController.playerState) { this.setPlayerState(this.audioController.playerState); } } public setVolume(volume: number) { this.audioController.setVolume(volume); } public setSpeed(speed: number) { this.audioController.setSpeed(speed); } public addNext(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) { let _musicItems: IMusic.IMusicItem[]; if (Array.isArray(musicItems)) { _musicItems = musicItems; } else { _musicItems = [musicItems]; } const now = Date.now(); let duplicateIndex = -1; _musicItems.forEach((item, index) => { _musicItems[index] = { ...item, [timeStampSymbol]: now, [sortIndexSymbol]: index, }; if (duplicateIndex === -1 && this.isCurrentMusic(item)) { duplicateIndex = index; } }); if (duplicateIndex !== -1) { _musicItems = [ _musicItems[duplicateIndex], ..._musicItems.slice(0, duplicateIndex), ..._musicItems.slice(duplicateIndex + 1), ]; } const startPart = []; const tailPart = []; const oldQueue = this.musicQueue; const uniqueMap = createUniqueMap(_musicItems); for (let i = 0; i < oldQueue.length; ++i) { if (i <= this.currentIndex) { if (!uniqueMap.has(oldQueue[i])) { startPart.push(oldQueue[i]); } } else { if (!uniqueMap.has(oldQueue[i])) { tailPart.push(oldQueue[i]); } } } const newQueue = [ ...startPart, ..._musicItems, ...tailPart, ]; this.setMusicQueue(newQueue); } public removeMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[] | number) { if (Array.isArray(musicItems)) { const uniqueMap = createUniqueMap(musicItems); const newQueue = []; const oldQueue = this.musicQueue; for (let i = 0; i < oldQueue.length; i++) { const musicItem = oldQueue[i]; if (uniqueMap.has(musicItem)) { if (this.currentIndex === i) { this.audioController.reset(); this.currentIndex = -1; resetProgress(); this.setCurrentMusic(null); } } else { newQueue.push(musicItem); if (this.currentIndex === i) { this.currentIndex = newQueue.length - 1; } } } this.setMusicQueue(newQueue); } else { const musicIndex = typeof musicItems === "number" ? musicItems : this.findMusicIndex(musicItems); if (musicIndex === -1) { return; } if (musicIndex === this.currentIndex) { this.audioController.reset(); this.currentIndex = -1; resetProgress(); this.setCurrentMusic(null); } const newQueue = [...this.musicQueue]; newQueue.splice(musicIndex, 1); this.setMusicQueue(newQueue); } } public async setQuality(quality: IMusic.IQualityKey) { const currentMusic = this.currentMusic; if (currentMusic && quality !== this.currentQuality) { const { mediaSource, quality: realQuality } = await this.fetchMediaSource(currentMusic, quality); if (this.isCurrentMusic(currentMusic)) { this.setTrack(mediaSource, currentMusic, { seekTo: this.progress.currentTime ?? 0, autoPlay: this.playerState === PlayerState.Playing, }); this.setCurrentQuality(realQuality); } } } public setRepeatMode(repeatMode: RepeatMode) { if (repeatMode === RepeatMode.Shuffle) { this.setMusicQueue(shuffle(this.musicQueue)); } else if (this.repeatMode === RepeatMode.Shuffle) { this.setMusicQueue(sortByTimestampAndIndex(this.musicQueue, true)); } repeatModeStore.setValue(repeatMode); setUserPreference("repeatMode", repeatMode); this.ee.emit(PlayerEvents.RepeatModeChanged, repeatMode); } public async setAudioOutputDevice(deviceId?: string) { try { await this.audioController.setSinkId(deviceId ?? ""); } catch (e) { logger.logError("设置音频输出设备失败", e); } } public setMusicQueue(musicQueue: IMusic.IMusicItem[]) { musicQueueStore.setValue(musicQueue); setUserPreferenceIDB("playList", musicQueue); this.indexMap.update(musicQueue); this.currentIndex = this.findMusicIndex(this.currentMusic); } public async fetchCurrentLyric(forceLoad = false) { const currentMusic = this.currentMusic; if (!currentMusic) { this.setCurrentLyric(null); return; } const currentLyric = this.lyric; if (!forceLoad && currentLyric && this.isCurrentMusic(currentLyric?.parser?.musicItem)) { return; } try { // 获取被关联的歌词 const linkedLyricItem = await getLinkedLyric(currentMusic); let lyricSource: ILyric.ILyricSource; if (linkedLyricItem) { lyricSource = await PluginManager.callPluginDelegateMethod( linkedLyricItem, "getLyric", linkedLyricItem, ); } if (!lyricSource && this.isCurrentMusic(currentMusic)) { lyricSource = await PluginManager.callPluginDelegateMethod( currentMusic, "getLyric", currentMusic, ); } if (!this.isCurrentMusic(currentMusic)) { return; } if (!lyricSource?.rawLrc && !lyricSource?.translation) { this.setCurrentLyric({}); } const parser = new LyricParser(lyricSource.rawLrc, { musicItem: currentMusic, translation: lyricSource.translation, }); this.setCurrentLyric({ parser, currentLrc: parser.getPosition(this.progress.currentTime || 0), }); } catch (e) { logger.logError("歌词解析失败", e); this.setCurrentLyric({}); } } private async fetchMediaSource(musicItem: IMusic.IMusicItem, quality?: IMusic.IQualityKey) { const defaultQuality = AppConfig.getConfig("playMusic.defaultQuality"); const whenQualityMissing = AppConfig.getConfig("playMusic.whenQualityMissing"); const qualityOrder = getQualityOrder(quality ?? defaultQuality, whenQualityMissing); let mediaSource: IPlugin.IMediaSourceResult | null = null; let realQuality: IMusic.IQualityKey = qualityOrder[0]; // 1. 判断是否已下载 const downloadedData = getInternalData( musicItem, "downloadData", ); if (downloadedData) { const { quality, path: _path } = downloadedData; if (await fsUtil.isFile(_path)) { return { quality, mediaSource: { url: fsUtil.addFileScheme(_path), }, }; } else { // TODO 删除 } } // 2. 如果没有下载 for (const quality of qualityOrder) { try { mediaSource = await PluginManager.callPluginDelegateMethod( { platform: musicItem.platform, }, "getMediaSource", musicItem, quality, ); if (!mediaSource?.url) { continue; } realQuality = quality; break; } catch { // pass } } return { quality: realQuality, mediaSource: mediaSource, }; } // 只读数据的设置 private setCurrentMusic(musicItem: IMusic.IMusicItem | null) { if (!this.isCurrentMusic(musicItem)) { currentMusicStore.setValue(musicItem); this.ee.emit(PlayerEvents.MusicChanged, musicItem); this.fetchCurrentLyric(); this.setCurrentLyric(null); if (musicItem) { setUserPreference("currentMusic", musicItem); } else { removeUserPreference("currentMusic"); } } else { // 相同的歌曲,不需要额外触发事件 currentMusicStore.setValue(musicItem); } } private setProgress(progress: CurrentTime) { progressStore.setValue(progress); setUserPreference("currentProgress", progress.currentTime); this.ee.emit(PlayerEvents.ProgressChanged, progress); } private setCurrentQuality(quality: IMusic.IQualityKey) { setUserPreference("currentQuality", quality); currentQualityStore.setValue(quality); } private setCurrentLyric(lyric?: ICurrentLyric) { const prev = this.lyric; currentLyricStore.setValue(lyric); if (lyric?.parser !== prev?.parser) { this.ee.emit(PlayerEvents.LyricChanged, lyric?.parser ?? null); } else if (lyric?.currentLrc !== prev?.currentLrc) { this.ee.emit(PlayerEvents.CurrentLyricChanged, lyric?.currentLrc ?? null); } } private setPlayerState(playerState: PlayerState) { playerStateStore.setValue(playerState); this.ee.emit(PlayerEvents.StateChanged, playerState); } // 获取音乐在播放列表中的下标 private findMusicIndex(musicItem?: IMusic.IMusicItem | null) { if (!musicItem) { return -1; } return this.indexMap.indexOf(musicItem); } private resetProgress() { resetProgress(); removeUserPreference("currentProgress"); } private setTrack(mediaSource: IPlugin.IMediaSourceResult, musicItem: IMusic.IMusicItem, options: ITrackOptions = { autoPlay: true, }) { this.resetProgress(); this.audioController.setTrackSource(mediaSource, musicItem); if (options.seekTo >= 0) { this.audioController.seekTo(options.seekTo); } if (options.autoPlay) { this.audioController.play(); } } // 判断某首歌是否是当前播放的歌曲 public isCurrentMusic(musicItem: IMusic.IMusicItem) { return isSameMedia(musicItem, this.currentMusic); } } export default new TrackPlayer(); ================================================ FILE: src/renderer/core/track-player/store.ts ================================================ import Store from "@/common/store"; import { ICurrentLyric } from "@renderer/core/track-player/enum"; import { PlayerState, RepeatMode } from "@/common/constant"; const initProgress = { currentTime: 0, duration: Infinity, }; /** 音乐队列 */ const musicQueueStore = new Store([]); /** 当前播放 */ const currentMusicStore = new Store(null); /** 当前歌词解析器 */ const currentLyricStore = new Store(null); /** 播放模式 */ const repeatModeStore = new Store(RepeatMode.Queue); /** 进度 */ const progressStore = new Store(initProgress); /** 播放状态 */ const playerStateStore = new Store(PlayerState.None); /** 音量 */ const currentVolumeStore = new Store(1); /** 速度 */ const currentSpeedStore = new Store(1); /** 音质 */ const currentQualityStore = new Store("standard"); function resetProgress() { progressStore.setValue(initProgress); } const _trackPlayerStore = { musicQueueStore, currentMusicStore, currentLyricStore, repeatModeStore, progressStore, playerStateStore, currentVolumeStore, currentSpeedStore, currentQualityStore, resetProgress, }; export default _trackPlayerStore; ================================================ FILE: src/renderer/document/bootstrap.ts ================================================ import { localPluginHash, PlayerState, RepeatMode, supportLocalMediaType } from "@/common/constant"; import MusicSheet from "../core/music-sheet"; import trackPlayer from "../core/track-player"; import localMusic from "../core/local-music"; import { setAutoFreeze } from "immer"; import Downloader from "../core/downloader"; import AppConfig from "@shared/app-config/renderer"; import { setupI18n } from "@/shared/i18n/renderer"; import ThemePack from "@/shared/themepack/renderer"; import { addToRecentlyPlaylist, setupRecentlyPlaylist } from "../core/recently-playlist"; import ServiceManager from "@shared/service-manager/renderer"; import { CurrentTime, PlayerEvents } from "@renderer/core/track-player/enum"; import { appWindowUtil, fsUtil } from "@shared/utils/renderer"; import PluginManager from "@shared/plugin-manager/renderer"; import messageBus from "@shared/message-bus/renderer/main"; import throttle from "lodash.throttle"; import { IAppState } from "@shared/message-bus/type"; import MusicDetail from "@renderer/components/MusicDetail"; import shortCut from "@shared/short-cut/renderer"; setAutoFreeze(false); export default async function () { await Promise.all([ AppConfig.setup(), PluginManager.setup(), ]); await Promise.all([ MusicSheet.frontend.setupMusicSheets(), trackPlayer.setup(), ]); await setupI18n(); shortCut.setup(); dropHandler(); clearDefaultBehavior(); setupCommandAndEvents(); setupDeviceChange(); localMusic.setupLocalMusic(); await Downloader.setupDownloader(); setupRecentlyPlaylist(); // 本地服务 ServiceManager.setup(); // 自动更新插件 if (AppConfig.getConfig("plugin.autoUpdatePlugin")) { const lastUpdated = +(localStorage.getItem("pluginLastupdatedTime") || 0); const now = Date.now(); if (Math.abs(now - lastUpdated) > 86400000) { localStorage.setItem("pluginLastupdatedTime", `${now}`); PluginManager.updateAllPlugins(); } } } function dropHandler() { document.addEventListener("drop", async (event) => { event.preventDefault(); event.stopPropagation(); console.log(event); const validMusicList: IMusic.IMusicItem[] = []; for (const f of event.dataTransfer.files) { if (f.type === "" && (await fsUtil.isFolder(f.path))) { validMusicList.push( ...(await PluginManager.callPluginDelegateMethod( { hash: localPluginHash, }, "importMusicSheet", f.path, )), ); } else if ( supportLocalMediaType.some((postfix) => f.path.endsWith(postfix)) ) { validMusicList.push( await PluginManager.callPluginDelegateMethod( { hash: localPluginHash, }, "importMusicItem", f.path, ), ); } else if (f.path.endsWith(".mftheme")) { // 主题包 const themeConfig = await ThemePack.installThemePack(f.path); if (themeConfig) { await ThemePack.selectTheme(themeConfig); } } } if (validMusicList.length) { trackPlayer.playMusicWithReplaceQueue(validMusicList); } }); document.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); }); } function clearDefaultBehavior() { const killSpaceBar = function (evt: any) { // https://greasyfork.org/en/scripts/25035-disable-space-bar-scrolling/code const target = evt.target || {}, isInput = "INPUT" == target.tagName || "TEXTAREA" == target.tagName || "SELECT" == target.tagName || "EMBED" == target.tagName; // if we're an input or not a real target exit if (isInput || !target.tagName) return; // if we're a fake input like the comments exit if ( target && target.getAttribute && target.getAttribute("role") === "textbox" ) return; // ignore the space if (evt.keyCode === 32) { evt.preventDefault(); } }; document.addEventListener("keydown", killSpaceBar, false); } /** 设置事件 */ function setupCommandAndEvents() { messageBus.onCommand("SkipToNext", () => { trackPlayer.skipToNext(); }); messageBus.onCommand("SkipToPrevious", () => { trackPlayer.skipToPrev(); }); messageBus.onCommand("TogglePlayerState", () => { if (trackPlayer.playerState === PlayerState.Playing) { trackPlayer.pause(); } else { trackPlayer.resume(); } }); messageBus.onCommand("SetRepeatMode", (mode) => { trackPlayer.setRepeatMode(mode); }); messageBus.onCommand("VolumeUp", (val = 0.04) => { trackPlayer.setVolume(Math.min(1, trackPlayer.volume + val)); }); messageBus.onCommand("VolumeDown", (val = 0.04) => { trackPlayer.setVolume(Math.max(0, trackPlayer.volume - val)); }); messageBus.onCommand("ToggleFavorite", async (item) => { const realItem = item || trackPlayer.currentMusic; if (MusicSheet.frontend.isFavoriteMusic(realItem)) { MusicSheet.frontend.removeMusicFromFavorite(realItem); } else { MusicSheet.frontend.addMusicToFavorite(realItem); } }); messageBus.onCommand("ToggleDesktopLyric", () => { const enableDesktopLyric = AppConfig.getConfig("lyric.enableDesktopLyric"); appWindowUtil.setLyricWindow(!enableDesktopLyric); AppConfig.setConfig({ "lyric.enableDesktopLyric": !enableDesktopLyric, }); }); messageBus.onCommand("OpenMusicDetailPage", () => { MusicDetail.show(); }); messageBus.onCommand("ToggleMainWindowVisible", () => { appWindowUtil.toggleMainWindowVisible(); }); const sendAppStateTo = (from: "main" | number) => { const appState: IAppState = { repeatMode: trackPlayer.repeatMode || RepeatMode.Queue, playerState: trackPlayer.playerState || PlayerState.None, musicItem: trackPlayer.currentMusicBasicInfo || null, lyricText: trackPlayer.lyric?.currentLrc?.lrc || null, parsedLrc: trackPlayer.lyric?.currentLrc || null, fullLyric: trackPlayer.lyric?.parser?.getLyricItems() || [], progress: trackPlayer.progress?.currentTime || 0, duration: trackPlayer.progress?.duration || 0, }; messageBus.syncAppState(appState, from); }; messageBus.onCommand("SyncAppState", (_, from) => { sendAppStateTo(from); }); sendAppStateTo("main"); // 状态同步 trackPlayer.on(PlayerEvents.StateChanged, state => { messageBus.syncAppState({ playerState: state, }); }); trackPlayer.on(PlayerEvents.RepeatModeChanged, mode => { messageBus.syncAppState({ repeatMode: mode, }); }); trackPlayer.on(PlayerEvents.CurrentLyricChanged, lyric => { messageBus.syncAppState({ lyricText: lyric.lrc, parsedLrc: lyric, }); }); trackPlayer.on(PlayerEvents.LyricChanged, lyric => { messageBus.syncAppState({ fullLyric: lyric?.getLyricItems?.() || [], }); }); const progressChangedHandler = throttle((currentTime: CurrentTime) => { messageBus.syncAppState({ progress: currentTime?.currentTime || 0, duration: currentTime.duration || 0, }); }, 800); trackPlayer.on(PlayerEvents.ProgressChanged, progressChangedHandler); // 最近播放 trackPlayer.on(PlayerEvents.MusicChanged, (musicItem) => { messageBus.syncAppState({ musicItem, lyricText: null, fullLyric: [], parsedLrc: null, progress: 0, duration: 0, }); addToRecentlyPlaylist(musicItem); }); } async function setupDeviceChange() { const getAudioDevices = async () => await navigator.mediaDevices.enumerateDevices().catch(() => []); let devices = (await getAudioDevices()) || []; navigator.mediaDevices.ondevicechange = async (evt) => { const newDevices = await getAudioDevices(); if ( newDevices.length < devices.length && AppConfig.getConfig("playMusic.whenDeviceRemoved") === "pause" ) { trackPlayer.pause(); } devices = newDevices; }; } ================================================ FILE: src/renderer/document/fallback.tsx ================================================ import trackPlayer from "../core/track-player"; import "./styles/fallback.scss"; interface IProps { error: Error, resetErrorBoundary: (...args: any[]) => void, } export default function Fallback(props: IProps) { const { error, resetErrorBoundary } = props; return (
    出现问题啦...
    请点击上方【重置配置项】按钮尝试修复,如果还有问题请将错误信息反馈到 GitHub 或发送到公众号【一只猫头猫】
    歌曲信息
                                {JSON.stringify(trackPlayer.currentMusic, null, 2)}
                            
    错误信息
                                {error.message}
                            
    {error.stack && (
                                    {error.stack}
                                
    )}
    ); } ================================================ FILE: src/renderer/document/index.html ================================================ Music Free
    ================================================ FILE: src/renderer/document/index.tsx ================================================ import ReactDOM from "react-dom/client"; import App from "../app"; import "animate.css"; import ModalComponent from "../components/Modal"; import bootstrap from "./bootstrap"; import { HashRouter, Route, Routes } from "react-router-dom"; import MainPage from "../pages/main-page"; import { ContextMenuComponent } from "../components/ContextMenu"; import { ToastContainer } from "react-toastify"; import "rc-slider/assets/index.css"; import "react-toastify/dist/ReactToastify.css"; import "./styles/index.scss"; // 全局样式 import { toastDuration } from "@/common/constant"; import useBootstrap from "./useBootstrap"; import logger from "@shared/logger/renderer"; import { ErrorBoundary } from "react-error-boundary"; import Fallback from "@renderer/document/fallback"; import AppConfig from "@shared/app-config/renderer"; import trackPlayer from "../core/track-player"; logger.logPerf("Create Bundle"); bootstrap().then(() => { logger.logPerf("Bundle Bootstrap Ready"); ReactDOM.createRoot(document.getElementById("root")).render( { // 删除软件配置 AppConfig.reset(); trackPlayer.reset(); }}>); }); function Root() { return ( <> }> }> }> ); } function BootstrapComponent(): null { useBootstrap(); return null; } ================================================ FILE: src/renderer/document/styles/base.scss ================================================ // 基础样式重置和全局样式 html, body { margin: 0; width: 100vw; height: 100vh; overflow: hidden; background-color: var(--backgroundColor); } // 链接样式 a { color: var(--linkColor); } // 美化后的滚动条样式 ::-webkit-scrollbar { width: var(--scrollbarWidth, 8px); height: 8px; } ::-webkit-scrollbar-track { background: transparent; border-radius: 6px; } ::-webkit-scrollbar-thumb { background: linear-gradient(135deg, rgba(0, 0, 0, 0.15) 0%, rgba(0, 0, 0, 0.25) 100%); border-radius: 6px; border: 2px solid transparent; background-clip: content-box; transition: all 0.2s ease; } ::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0.35) 100%); background-clip: content-box; } ::-webkit-scrollbar-thumb:active { background: linear-gradient(135deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.45) 100%); background-clip: content-box; } ::-webkit-scrollbar-corner { background: transparent; } // 在深色主题下的滚动条样式优化 @media (prefers-color-scheme: dark) { ::-webkit-scrollbar-thumb { background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.25) 100%); background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { background: linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.35) 100%); background-clip: content-box; } ::-webkit-scrollbar-thumb:active { background: linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.45) 100%); background-clip: content-box; } } ================================================ FILE: src/renderer/document/styles/components.scss ================================================ // 表单组件样式 input { outline: none; font-size: 1rem; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--dividerColor); box-sizing: border-box; background: var(--placeholderColor); color: var(--textColor); transition: all 0.2s ease; &:focus { border-color: var(--primaryColor); background: var(--backgroundColor); box-shadow: 0 0 0 2px rgba(241, 125, 52, 0.1); } &::placeholder { opacity: 0.6; color: var(--textColor); } } // 按钮组件样式 div[role="button"] { cursor: pointer; user-select: none; transition: all 0.2s ease; &[data-disabled]:not([data-disabled="false"]) { cursor: default; opacity: 0.5; pointer-events: none; } &[data-type="primaryButton"] { background-color: var(--primaryColor); font-size: 1em; padding: 0.6em 1em; border-radius: 8px; color: white; width: fit-content; line-height: 1em; display: flex; justify-content: center; align-items: center; box-shadow: 0 2px 4px rgba(241, 125, 52, 0.2); &:hover { background-color: color-mix(in srgb, var(--primaryColor) 90%, black); box-shadow: 0 4px 8px rgba(241, 125, 52, 0.3); transform: translateY(-1px); } &:active { transform: translateY(0); box-shadow: 0 2px 4px rgba(241, 125, 52, 0.2); } } &[data-type="normalButton"] { font-size: 1em; padding: 0.6em 1em; border-radius: 8px; color: var(--textColor); border: 1px solid currentColor; width: fit-content; line-height: 1em; background-color: color-mix(in srgb, currentColor 8%, transparent); display: flex; justify-content: center; align-items: center; &:hover { background-color: color-mix(in srgb, currentColor 15%, transparent); transform: translateY(-1px); } &:active { transform: translateY(0); background-color: color-mix(in srgb, currentColor 20%, transparent); } } &[data-type="dangerButton"] { font-size: 1em; padding: 0.6em 1em; border-radius: 8px; color: var(--dangerColor); border: 1px solid currentColor; width: fit-content; line-height: 1em; display: flex; justify-content: center; align-items: center; background-color: color-mix(in srgb, var(--dangerColor) 8%, transparent); &:hover { background-color: color-mix(in srgb, var(--dangerColor) 15%, transparent); transform: translateY(-1px); } &:active { transform: translateY(0); background-color: color-mix(in srgb, var(--dangerColor) 20%, transparent); } &[data-fill="true"] { color: white; background: var(--dangerColor); box-shadow: 0 2px 4px rgba(252, 95, 95, 0.2); &:hover { background: color-mix(in srgb, var(--dangerColor) 90%, black); box-shadow: 0 4px 8px rgba(252, 95, 95, 0.3); } &:active { box-shadow: 0 2px 4px rgba(252, 95, 95, 0.2); } } } } ================================================ FILE: src/renderer/document/styles/fallback.scss ================================================ // 错误边界页面样式 .fallback-container { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; min-height: 100vh; max-height: 100vh; padding: 24px; background-color: var(--backgroundColor); color: var(--textColor); font-size: 14px; line-height: 1.6; overflow-y: auto; // 为整个容器添加滚动条样式 &::-webkit-scrollbar { width: var(--scrollbarWidth); } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: var(--dividerColor); border-radius: 4px; } &::-webkit-scrollbar-thumb:hover { background: var(--listActiveColor); } .fallback-content { max-width: 800px; width: 100%; background: var(--backgroundColor); border-radius: 12px; padding: 32px; box-shadow: 0 4px 20px var(--shadowColor); border: 1px solid var(--dividerColor); margin: auto; flex-shrink: 0; } .fallback-title { font-size: 24px; font-weight: 600; color: var(--dangerColor); margin-bottom: 16px; text-align: center; display: flex; align-items: center; justify-content: center; gap: 8px; &::before { content: "⚠️"; font-size: 28px; } } .fallback-description { color: var(--textColor); margin-bottom: 24px; text-align: center; opacity: 0.8; line-height: 1.8; } .fallback-section { margin-bottom: 24px; .section-title { font-size: 16px; font-weight: 600; color: var(--textColor); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid var(--dividerColor); } .section-content { background: var(--placeholderColor); border-radius: 8px; padding: 16px; border: 1px solid var(--dividerColor); max-height: 300px; overflow-y: auto; &::-webkit-scrollbar { width: var(--scrollbarWidth); } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: var(--dividerColor); border-radius: 4px; } &::-webkit-scrollbar-thumb:hover { background: var(--listActiveColor); } pre { margin: 0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; &::-webkit-scrollbar { width: var(--scrollbarWidth); } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: var(--dividerColor); border-radius: 4px; } &::-webkit-scrollbar-thumb:hover { background: var(--listActiveColor); } &.error-message { color: var(--dangerColor); background: rgba(252, 95, 95, 0.1); padding: 12px; border-radius: 6px; border-left: 4px solid var(--dangerColor); margin-bottom: 8px; } &.music-info { color: var(--infoColor); background: rgba(10, 149, 200, 0.1); padding: 12px; border-radius: 6px; border-left: 4px solid var(--infoColor); } } } } .fallback-actions { display: flex; justify-content: center; margin-top: 32px; margin-bottom: 32px; } .reset-button { background: linear-gradient(135deg, var(--dangerColor), #ff4757); color: white; border: none; border-radius: 8px; padding: 12px 24px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all var(--animate-duration) ease; box-shadow: 0 4px 12px rgba(252, 95, 95, 0.3); display: flex; align-items: center; gap: 8px; &:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(252, 95, 95, 0.4); background: linear-gradient(135deg, #ff4757, var(--dangerColor)); } &:active { transform: translateY(0); box-shadow: 0 2px 8px rgba(252, 95, 95, 0.3); } &::before { content: "🔄"; font-size: 16px; } } // 响应式设计 @media (max-width: 768px) { padding: 16px; .fallback-content { padding: 24px; } .fallback-title { font-size: 20px; } .section-content { max-height: 250px; pre { font-size: 11px; } } } // 深色主题适配 @media (prefers-color-scheme: dark) { .fallback-content { background: rgba(255, 255, 255, 0.05); } .section-content { background: rgba(255, 255, 255, 0.03); } } } ================================================ FILE: src/renderer/document/styles/index.scss ================================================ // 主样式文件 - 导入所有样式模块 @forward './variables.scss'; @forward './base.scss'; @forward './utilities.scss'; @forward './components.scss'; @forward './tables.scss'; @forward './fallback.scss'; ================================================ FILE: src/renderer/document/styles/tables.scss ================================================ // 表格组件样式 table { width: 100%; table-layout: fixed; user-select: none; border-collapse: collapse; $row-height: 2.6rem; & thead { & tr { height: $row-height; & th { text-align: left; border-bottom: 1px solid var(--dividerColor); font-weight: 600; color: var(--textColor); padding: 0 12px; &:first-child { text-align: center; } } } } & tbody { & tr { height: $row-height; transition: background-color 0.15s ease; & td { text-align: left; padding: 0 12px; border-bottom: 1px solid transparent; &:first-child { text-align: center; } } &:nth-child(even) { background-color: var(--listEvenColor); } &:hover { background: var(--listHoverColor); } &[data-active="true"] { background: var(--listActiveColor); } } } } // Toast组件位置调整 .Toastify__toast-container--top-right { top: calc(var(--appHeaderHeight) + 1rem); } // iframe样式 iframe { width: 100%; height: 100%; border: none; position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; } ================================================ FILE: src/renderer/document/styles/utilities.scss ================================================ // 工具类样式 .blur10 { backdrop-filter: blur(10px); } .divider { width: 100%; height: 1px; background-color: var(--dividerColor); margin-top: 12px; margin-bottom: 12px; } .shadow { box-shadow: var(--shadow, var(--shadowColor) 2px 2px 8px); } .background-color { background: var(--backgroundColor); } .backdrop-color { background: var(--backdropColor, var(--backgroundColor)); } .opacity-button { opacity: 0.6; transition: opacity 0.2s ease; &:hover { opacity: 1; } } .highlight { color: var(--primaryColor) !important; } // 列表行为样式 .list-behavior { cursor: pointer; transition: background-color 0.15s ease; &:hover { background-color: var(--listHoverColor); } &:active { background-color: var(--listActiveColor); } &[data-selected="true"] { background-color: var(--listActiveColor); } } ================================================ FILE: src/renderer/document/styles/variables.scss ================================================ // 全局变量定义模块 // =========================== // 1. 主题色彩变量 (Theme Colors) // =========================== $primary-color: #f17d34; $background-color: #fdfdfd; $text-color: #333333; $link-color: #0c66fc; // =========================== // 2. 状态色彩变量 (Status Colors) // =========================== $success-color: #08a34c; $danger-color: #fc5f5f; $info-color: #0a95c8; // =========================== // 3. 交互色彩变量 (Interactive Colors) // =========================== $divider-color: rgba(0, 0, 0, 0.1); $list-even-color: rgba(0, 0, 0, 0.05); $list-hover-color: rgba(0, 0, 0, 0.05); $list-active-color: rgba(0, 0, 0, 0.1); $mask-color: rgba(51, 51, 51, 0.5); $shadow-color: rgba(0, 0, 0, 0.2); $placeholder-color: #f4f4f4; // =========================== // 4. 尺寸变量 (Dimensions) // =========================== $app-header-height: 54px; $app-music-bar-height: 64px; $scrollbar-width: 12px; $font-size: 13px; // =========================== // 5. 动效变量 (Animation) // =========================== $animate-duration: 300ms; // CSS自定义属性定义(供主题系统使用) :root { // =========================== // 1. 主题色彩 (Theme Colors) // =========================== --primaryColor: #{$primary-color}; --backgroundColor: #{$background-color}; --textColor: #{$text-color}; --linkColor: #{$link-color}; // =========================== // 2. 状态色彩 (Status Colors) // =========================== --successColor: #{$success-color}; --dangerColor: #{$danger-color}; --infoColor: #{$info-color}; // =========================== // 3. 文本色彩 (Text Colors) // =========================== --headerTextColor: white; // =========================== // 4. 交互色彩 (Interactive Colors) // =========================== --dividerColor: #{$divider-color}; --listEvenColor: #{$list-even-color}; --listHoverColor: #{$list-hover-color}; --listActiveColor: #{$list-active-color}; --maskColor: #{$mask-color}; --shadowColor: #{$shadow-color}; --placeholderColor: #{$placeholder-color}; // =========================== // 5. 布局尺寸 (Layout Dimensions) // =========================== --appHeaderHeight: #{$app-header-height}; --appMusicBarHeight: #{$app-music-bar-height}; // =========================== // 6. 组件尺寸 (Component Dimensions) // =========================== --scrollbarWidth: #{$scrollbar-width}; --fontSize: #{$font-size}; // =========================== // 7. 动效变量 (Animation) // =========================== --animate-duration: #{$animate-duration} !important; // =========================== // 8. 兼容性变量 (Deprecated) // =========================== --scrollbar-width: var(--scrollbarWidth); // @deprecated 使用 --scrollbarWidth } ================================================ FILE: src/renderer/document/useBootstrap.ts ================================================ import { useEffect, useLayoutEffect } from "react"; import { useNavigate } from "react-router-dom"; import checkUpdate from "../utils/check-update"; import Themepack from "@/shared/themepack/renderer"; import logger from "@shared/logger/renderer"; import AppConfig from "@shared/app-config/renderer"; import messageBus from "@shared/message-bus/renderer/main"; export default function useBootstrap() { const navigate = useNavigate(); useLayoutEffect(() => { Themepack.setupThemePacks(); }, []); useEffect(() => { messageBus.onCommand("Navigate", (route) => { navigate(route); }); if (AppConfig.getConfig("normal.checkUpdate")) { checkUpdate(); } logger.logPerf("Bundle First Screen"); }, []); } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/index.scss ================================================ .side-bar-container { width: 220px; height: 100%; flex-shrink: 0; flex-grow: 0; overflow-y: auto; box-sizing: border-box; padding: 12px 0; border-right:1px solid var(--dividerColor); position: relative; } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/index.tsx ================================================ import ListItem from "./widgets/ListItem"; import "./index.scss"; import MySheets from "./widgets/MySheets"; import { useMatch, useNavigate } from "react-router"; import StarredSheets from "./widgets/StarredSheets"; import { useTranslation } from "react-i18next"; export default function () { const navigate = useNavigate(); const routePathMatch = useMatch("/main/:routePath"); const { t } = useTranslation(); const options = [ { iconName: "trophy", title: t("side_bar.toplist"), route: "toplist", }, { iconName: "fire", title: t("side_bar.recommend_sheets"), route: "recommend-sheets", }, { iconName: "array-download-tray", title: t("side_bar.download_management"), route: "download", }, { iconName: "folder-open", title: t("side_bar.local_music"), route: "local-music", }, { iconName: "code-bracket-square", title: t("side_bar.plugin_management"), route: "plugin-manager-view", }, { iconName: "clock", title: t("side_bar.recently_play"), route: "recently_play", }, ] as const; return (
    {options.map((item) => ( { navigate(`/main/${item.route}`); }} > ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/ListItem/index.scss ================================================ .side-bar--list-item-container { $height: 3rem; height: $height; font-size: 1rem; width: 100%; position: relative; display: flex; align-items: center; box-sizing: border-box; padding-left: 1rem; user-select: none; cursor: pointer; color: var(--textColor); transition: all linear 100ms; & svg { width: 1.6rem; height: 1.6rem; margin-right: 0.5rem; flex-shrink: 0; } & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 0.5rem; } &:hover { background: var(--listHoverColor); } &[data-selected="true"] { color: var(--primaryColor); background: var(--listActiveColor); &::before { content: ""; position: absolute; width: 4px; left: 0; top: 0; height: $height; background-color: var(--primaryColor); } } } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/ListItem/index.tsx ================================================ import SvgAsset, { SvgAssetIconNames } from "@/renderer/components/SvgAsset"; import "./index.scss"; interface IProps { selected?: boolean; onClick?: () => void; onContextMenu?: (...args: any) => void; iconName?: SvgAssetIconNames; title?: string; } export default function ListItem(props: IProps) { const { selected, onClick, iconName, title, onContextMenu } = props ?? {}; return (
    {iconName ? : null} {title ?? ""}
    ); } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/MySheets/index.scss ================================================ .side-bar-container--my-sheets { & .divider { margin-top: 0.5rem; width: 100%; height: 1px; background-color: var(--dividerColor); } & .title { height: 3rem; display: flex; align-items: center; padding-left: 1rem; padding-right: 0.5rem; opacity: 0.7; user-select: none; $tag-size: 4px; & .my-sheets { display: flex; align-items: center; flex: 1; &::after { content: ""; width: 0; height: 0; margin-left: 0.5rem; border: $tag-size solid transparent; border-left-color: currentColor; transform-origin: left center; transition: transform linear 100ms; } } &[data-headlessui-state="open"] { & .my-sheets { &::after { transform: rotate(90deg); } } } & .option-btn { $size: 18px; width: $size; height: $size; margin-left: 6px; & svg { width: $size; height: $size; } } } } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/MySheets/index.tsx ================================================ import "./index.scss"; import ListItem from "../ListItem"; import { useMatch, useNavigate } from "react-router-dom"; import { Disclosure } from "@headlessui/react"; import MusicSheet, { defaultSheet } from "@/renderer/core/music-sheet"; import SvgAsset from "@/renderer/components/SvgAsset"; import { hideModal, showModal } from "@/renderer/components/Modal"; import { localPluginName } from "@/common/constant"; import { showContextMenu } from "@/renderer/components/ContextMenu"; import { useTranslation } from "react-i18next"; import { useSupportedPlugin } from "@shared/plugin-manager/renderer"; export default function MySheets() { const sheetIdMatch = useMatch( `/main/musicsheet/${encodeURIComponent(localPluginName)}/:sheetId`, ); const currentSheetId = sheetIdMatch?.params?.sheetId; const musicSheets = MusicSheet.frontend.useAllSheets(); const navigate = useNavigate(); const { t } = useTranslation(); const importablePlugins = useSupportedPlugin("importMusicSheet"); return (
    {t("side_bar.my_sheets")}
    { e.stopPropagation(); showModal("ImportMusicSheet", { plugins: importablePlugins, }); }} >
    { e.stopPropagation(); showModal("AddNewSheet"); }} >
    {musicSheets.map((item) => ( { if (currentSheetId !== item.id) { navigate(`/main/musicsheet/${encodeURIComponent(localPluginName)}/${encodeURIComponent(item.id)}`); } }} onContextMenu={(e) => { if (item.id === defaultSheet.id) { return; } showContextMenu({ x: e.clientX, y: e.clientY, menuItems: [ { title: t("side_bar.rename_sheet"), icon: "pencil-square", show: item.id !== defaultSheet.id, onClick() { showModal("SimpleInputWithState", { placeholder: t( "modal.create_local_sheet_placeholder", ), maxLength: 30, title: t("side_bar.rename_sheet"), defaultValue: item.title, async onOk(text) { await MusicSheet.frontend.updateSheet(item.id, { title: text, }); hideModal(); }, }); }, }, { title: t("side_bar.delete_sheet"), icon: "trash", show: item.id !== defaultSheet.id, onClick() { MusicSheet.frontend.removeSheet(item.id).then(() => { if (currentSheetId === item.id) { navigate( `/main/musicsheet/${encodeURIComponent(localPluginName)}/${defaultSheet.id}`, { replace: true, }, ); } }); }, }, ], }); }} selected={currentSheetId === item.id} title={ item.id === defaultSheet.id ? t("media.default_favorite_sheet_name") : item.title } > ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/StarredSheets/index.scss ================================================ .side-bar-container--starred-sheets { & .title { height: 3rem; display: flex; justify-content: space-between; align-items: center; padding-left: 1rem; padding-right: 0.5rem; opacity: 0.7; user-select: none; $tag-size: 4px; & .my-sheets { display: flex; align-items: center; &::after { content: ""; width: 0; height: 0; margin-left: 0.5rem; border: $tag-size solid transparent; border-left-color: currentColor; transform-origin: left center; transition: transform linear 100ms; } } &[data-headlessui-state="open"] { & .my-sheets { &::after { transform: rotate(90deg); } } } } } ================================================ FILE: src/renderer/pages/main-page/components/SideBar/widgets/StarredSheets/index.tsx ================================================ import "./index.scss"; import ListItem from "../ListItem"; import { useMatch, useNavigate } from "react-router-dom"; import { Disclosure } from "@headlessui/react"; import MusicSheet, { defaultSheet } from "@/renderer/core/music-sheet"; import { localPluginName } from "@/common/constant"; import { showContextMenu } from "@/renderer/components/ContextMenu"; import { useTranslation } from "react-i18next"; export default function StarredSheets() { const sheetIdMatch = useMatch("/main/musicsheet/:platform/:sheetId"); const currentPlatform = sheetIdMatch?.params?.platform; const currentSheetId = sheetIdMatch?.params?.sheetId; const starredSheets = MusicSheet.frontend.useAllStarredSheets(); const navigate = useNavigate(); const { t } = useTranslation(); return (
    {t("side_bar.starred_sheets")}
    {starredSheets.map((item) => ( { if ( !( currentSheetId === item.id && currentPlatform === item.platform ) ) { // 如果不是相同歌单 navigate(`/main/musicsheet/${item.platform}/${item.id}`, { state: { sheetItem: item, }, }); } }} onContextMenu={(e) => { showContextMenu({ x: e.clientX, y: e.clientY, menuItems: [ { title: t("side_bar.unstar_sheet"), icon: "trash", onClick() { MusicSheet.frontend.unstarMusicSheet(item).then(() => { if ( currentSheetId === item.id && currentPlatform === item.platform ) { navigate( `/main/musicsheet/${localPluginName}/${defaultSheet.id}`, { replace: true, }, ); } }); }, }, ], }); }} selected={ currentSheetId === item.id && currentPlatform === item.platform } title={item.title} > ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/index.scss ================================================ .page-container { flex: auto; overflow-y: auto; overflow-x: hidden; width: 100%; padding-left: 1.5rem; padding-right: 1.5rem; position: relative; } .page-container-full-width { padding-left: 0; padding-right: 0; } .page-container-fw { @extend .page-container; padding-left: 0; padding-right: 0; } ================================================ FILE: src/renderer/pages/main-page/index.tsx ================================================ import { Route, Routes } from "react-router-dom"; import SideBar from "./components/SideBar"; import PluginManagerView from "./views/plugin-manager-view"; import MusicSheetView from "./views/music-sheet-view"; import SearchView from "./views/search-view"; import AlbumView from "./views/album-view"; import ArtistView from "./views/artist-view"; import ToplistView from "./views/toplist-view"; import TopListDetailView from "./views/toplist-detail-view"; import RecommendSheetsView from "./views/recommend-sheets-view"; import SettingView from "./views/setting-view"; import LocalMusicView from "./views/local-music-view"; import Empty from "@/renderer/components/Empty"; import DownloadView from "./views/download-view"; import ThemeView from "./views/theme-view"; import RecentlyPlayView from "./views/recently-play-view"; import "./index.scss"; export default function MainPage() { return ( <> }> } > } > } > } > }> } > } > } > }> }> }> } > }> ); } ================================================ FILE: src/renderer/pages/main-page/views/album-view/hooks/useAlbumDetail.ts ================================================ import { RequestStateCode } from "@/common/constant"; import { useCallback, useEffect, useRef, useState } from "react"; import PluginManager from "@shared/plugin-manager/renderer"; const idleCode = [ RequestStateCode.IDLE, RequestStateCode.FINISHED, RequestStateCode.PARTLY_DONE, ]; export default function useAlbumDetail( originalAlbumItem: IAlbum.IAlbumItem | null, ) { const currentPageRef = useRef(1); const [requestState, setRequestState] = useState( RequestStateCode.IDLE, ); const [albumItem, setAlbumItem] = useState( originalAlbumItem, ); const [musicList, setMusicList] = useState( originalAlbumItem?.musicList ?? [], ); const getAlbumDetail = useCallback( async function () { if (originalAlbumItem === null || !idleCode.includes(requestState)) { return; } try { setRequestState( currentPageRef.current === 1 ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE, ); const result = await PluginManager.callPluginDelegateMethod( originalAlbumItem, "getAlbumInfo", originalAlbumItem, currentPageRef.current, ); if (result === null || result === undefined) { throw new Error(); } if (result?.albumItem) { setAlbumItem((prev) => ({ ...(prev ?? {}), ...(result.albumItem as IAlbum.IAlbumItem), platform: originalAlbumItem.platform, })); } if (result?.musicList) { const currentPage = currentPageRef.current; setMusicList((prev) => { if (currentPage === 1) { return result?.musicList ?? prev; } else { return [...prev, ...(result.musicList ?? [])]; } }); } setRequestState( result.isEnd ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE, ); currentPageRef.current += 1; } catch (e) { setRequestState(RequestStateCode.IDLE); } }, [requestState], ); useEffect(() => { getAlbumDetail(); }, []); console.log(musicList); return [requestState, albumItem, musicList, getAlbumDetail] as const; } ================================================ FILE: src/renderer/pages/main-page/views/album-view/index.scss ================================================ ================================================ FILE: src/renderer/pages/main-page/views/album-view/index.tsx ================================================ import MusicSheetlikeView from "@/renderer/components/MusicSheetlikeView"; import { useParams } from "react-router-dom"; import { useMemo } from "react"; import "./index.scss"; import useAlbumDetail from "./hooks/useAlbumDetail"; export default function AlbumView() { const params = useParams(); const originalAlbumItem = useMemo(() => { const sheetInState = history.state.usr?.albumItem ?? {}; return { ...sheetInState, platform: params?.platform, id: params?.id, } as IAlbum.IAlbumItem; }, [params?.platform, params?.id]); const [requestState, albumItem, musicList, getAlbumDetail] = useAlbumDetail(originalAlbumItem); return (
    ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Body/index.scss ================================================ .artist-view--body-container { margin-top: 12px; & .tab-panel-container{ min-height: 300px; } } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Body/index.tsx ================================================ import { Tab } from "@headlessui/react"; import "./index.scss"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import SwitchCase from "@/renderer/components/SwitchCase"; import MusicResult from "./widgets/MusicResult"; import AlbumResult from "./widgets/AlbumResult"; interface IBodyProps { artistItem: IArtist.IArtistItem; } const supportedMediaType = ["music", "album"]; export default function Body(props: IBodyProps) { const { artistItem } = props; const [currentMediaType, setCurrentMediaType] = useState("music"); const { t } = useTranslation(); return (
    { setCurrentMediaType(supportedMediaType[index]); }} > {supportedMediaType.map((type) => ( {t(`media.media_type_${type}`)} ))} {supportedMediaType.map((type) => ( ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Body/widgets/AlbumResult/index.scss ================================================ .artist-view--album-result-container { margin-top: 14px; } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Body/widgets/AlbumResult/index.tsx ================================================ import { useEffect } from "react"; import useQueryArtist from "../../../../hooks/useQueryArtist"; import { queryResultStore } from "../../../../store"; import Condition from "@/renderer/components/Condition"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; import MusicSheetlikeList from "@/renderer/components/MusicSheetlikeList"; import "./index.scss"; import { useNavigate } from "react-router-dom"; interface IBodyProps { artistItem: IArtist.IArtistItem; } export default function AlbumResult(props: IBodyProps) { const { artistItem } = props; const queryArtist = useQueryArtist(); const queryResult = queryResultStore.useValue().album; const navigate = useNavigate(); useEffect(() => { queryArtist(artistItem, 1, "album"); }, []); return (
    } > { navigate(`/main/album/${encodeURIComponent(mediaItem.platform)}/${encodeURIComponent(mediaItem.id)}`, { state: { albumItem: mediaItem, }, }); }} onLoadMore={() => { queryArtist(artistItem, undefined, "album"); }} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Body/widgets/MusicResult/index.tsx ================================================ import MusicList from "@/renderer/components/MusicList"; import { useEffect } from "react"; import useQueryArtist from "../../../../hooks/useQueryArtist"; import { queryResultStore } from "../../../../store"; import Condition from "@/renderer/components/Condition"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; interface IBodyProps { artistItem: IArtist.IArtistItem; } export default function MusicResult(props: IBodyProps) { const { artistItem } = props; const queryArtist = useQueryArtist(); const queryResult = queryResultStore.useValue().music; useEffect(() => { queryArtist(artistItem, 1, "music"); }, []); return ( } > { queryArtist(artistItem, undefined, "music"); }} > ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Header/index.scss ================================================ .artist-view--header-container { margin-top: 24px; display: flex; min-height: 160px; & img { width: 160px; height: 160px; border-radius: 12px; user-select: none; -webkit-user-drag: none; object-fit: cover; } & .artist-info { flex: 1; padding-left: 1.2rem; & .title-container { display: flex; align-items: center; -webkit-user-drag: none; user-select: text; & .title { flex: 1; font-size: 1.4rem; font-weight: 600; margin-left: 8px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; } } & .info-container { margin-top: 16px; font-size: 0.9rem; opacity: 0.8; line-height: 2; & span { margin-right: 24px; } } & .description-container { &[data-fold="true"] { display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; } } } } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/components/Header/index.tsx ================================================ import { setFallbackAlbum } from "@/renderer/utils/img-on-error"; import albumImg from "@/assets/imgs/album-cover.jpg"; import Tag from "@/renderer/components/Tag"; import Condition from "@/renderer/components/Condition"; import "./index.scss"; import { useTranslation } from "react-i18next"; interface IProps { artistItem: IArtist.IArtistItem; } export default function Header(props: IProps) { const { artistItem } = props; const { t } = useTranslation(); return (
    {artistItem?.platform}
    {artistItem?.name ?? t("media.unknown_artist")}
    { const dataset = e.currentTarget.dataset; dataset.fold = dataset.fold === "true" ? "false" : "true"; }} > {artistItem?.description}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/hooks/useQueryArtist.ts ================================================ import { produce } from "immer"; import { useCallback } from "react"; import { RequestStateCode } from "@/common/constant"; import { queryResultStore } from "../store"; import PluginManager from "@shared/plugin-manager/renderer"; const setQueryResults = queryResultStore.setValue; export default function useQueryArtist() { const queryResults = queryResultStore.useValue(); const queryArtist = useCallback( async ( artist: IArtist.IArtistItem, page?: number, type: IArtist.ArtistMediaType = "music", ) => { const prevResult = queryResults[type]; if ( prevResult?.state & RequestStateCode.PENDING_FIRST_PAGE || prevResult?.state === RequestStateCode.FINISHED || page <= prevResult.page ) { return; } page = page ?? (prevResult.page ?? 0) + 1; try { setQueryResults( produce((draft) => { draft[type].state = page === 1 ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE; }), ); const result = await PluginManager.callPluginDelegateMethod( artist, "getArtistWorks", artist, page, type, ); setQueryResults( produce((draft) => { draft[type].page = page; draft[type].state = result?.isEnd === false ? RequestStateCode.PARTLY_DONE : RequestStateCode.FINISHED; draft[type].data = (draft[type].data ?? [] as any[]).concat( result?.data ?? [], ); }), ); } catch (e) { setQueryResults( produce((draft) => { draft[type].state = page === 1 ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE; }), ); } }, [queryResults], ); return queryArtist; } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/index.scss ================================================ .artist-view--container {} ================================================ FILE: src/renderer/pages/main-page/views/artist-view/index.tsx ================================================ import { useParams } from "react-router-dom"; import Header from "./components/Header"; import "./index.scss"; import { useEffect, useMemo } from "react"; import Body from "./components/Body"; import { initQueryResult, queryResultStore } from "./store"; export default function ArtistView() { const params = useParams(); const artistItem = useMemo(() => { const artistInState = history.state.usr?.artistItem ?? {}; return { ...artistInState, platform: params?.platform, id: params?.id, } as IArtist.IArtistItem; }, [params?.platform, params?.id]); useEffect(() => { return () => { queryResultStore.setValue(initQueryResult); }; }); return (
    ); } ================================================ FILE: src/renderer/pages/main-page/views/artist-view/store/index.ts ================================================ import { RequestStateCode } from "@/common/constant"; import Store from "@/common/store"; export interface IQueryResult< T extends IArtist.ArtistMediaType = IArtist.ArtistMediaType, > { state?: RequestStateCode; page?: number; data?: IMedia.SupportMediaItem[T][]; } type IQueryResults< K extends IArtist.ArtistMediaType = IArtist.ArtistMediaType, > = { [T in K]: IQueryResult; }; export const initQueryResult: IQueryResults = { music: {}, album: {}, }; export const queryResultStore = new Store(initQueryResult); ================================================ FILE: src/renderer/pages/main-page/views/download-view/components/Downloaded/index.tsx ================================================ import MusicList from "@/renderer/components/MusicList"; import Downloader from "@/renderer/core/downloader"; import { useRef } from "react"; export default function Downloaded() { const downloadedList = Downloader.useDownloadedMusicList(); const musicListContainerRef = useRef(); return (
    musicListContainerRef.current.offsetTop, }} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/download-view/components/Downloading/DownloadStatus.tsx ================================================ import { DownloadState } from "@/common/constant"; import { isSameMedia } from "@/common/media-util"; import { normalizeFileSize } from "@/common/normalize-util"; import Downloader from "@/renderer/core/downloader"; import React from "react"; import { useTranslation } from "react-i18next"; interface IProps { musicItem: IMusic.IMusicItem; } function DownloadStatus(props: IProps) { const { musicItem } = props; const { t } = useTranslation(); const downloadStatus = Downloader.useDownloadStatus(musicItem); if (!downloadStatus) { return -; } else if (downloadStatus.state === DownloadState.WAITING) { return {t("download_page.waiting")}; } else if (downloadStatus.state === DownloadState.ERROR) { return ( {t("download_page.failed")}: {downloadStatus.msg} ); } else if (downloadStatus.state === DownloadState.DOWNLOADING) { return ( {normalizeFileSize(downloadStatus.downloaded ?? 0)} /{" "} {normalizeFileSize(downloadStatus.total ?? 0)} ); } } export default React.memo(DownloadStatus, (prev, curr) => isSameMedia(prev.musicItem, curr.musicItem), ); ================================================ FILE: src/renderer/pages/main-page/views/download-view/components/Downloading/index.scss ================================================ .downloading-container { width: 100%; min-height: 300px; & td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } ================================================ FILE: src/renderer/pages/main-page/views/download-view/components/Downloading/index.tsx ================================================ import Tag from "@/renderer/components/Tag"; import Downloader from "@/renderer/core/downloader"; import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, } from "@tanstack/react-table"; import "./index.scss"; import { i18n } from "@/shared/i18n/renderer"; import useVirtualList from "@/hooks/useVirtualList"; import DownloadStatus from "./DownloadStatus"; const columnHelper = createColumnHelper(); const estimizeItemHeight = 2.6 * 13; // lineheight 2.6rem const { t } = i18n; const columnDef = [ columnHelper.accessor((_, index) => index + 1, { cell: (info) => info.getValue(), header: () => "#", id: "index", minSize: 40, maxSize: 40, size: 40, }), columnHelper.accessor("title", { header: () => t("media.media_title"), size: 200, cell: (info) => {info.getValue()}, }), columnHelper.accessor("artist", { header: () => t("media.media_type_artist"), size: 80, cell: (info) => {info.getValue()}, }), columnHelper.accessor("album", { header: () => t("media.media_type_album"), size: 80, cell: (info) => {info.getValue()}, }), columnHelper.display({ header: () => t("common.status"), size: 180, id: "status", cell: (info) => { return ; }, }), columnHelper.accessor("platform", { header: () => t("media.media_platform"), size: 100, cell: (info) => {info.getValue()}, }), ]; export default function Downloading() { const downloadingQueue = Downloader.useDownloadingMusicList(); const table = useReactTable({ debugAll: false, data: downloadingQueue, columns: columnDef, getCoreRowModel: getCoreRowModel(), }); const virtualController = useVirtualList({ data: table.getRowModel().rows, scrollElementQuery: "#page-container", estimateItemHeight: estimizeItemHeight, }); return (
    {table.getHeaderGroups()[0].headers.map((header) => ( ))} {virtualController.virtualItems.map((virtualItem, index) => { const dataItem = virtualItem.dataItem; const musicItem = dataItem.original; // todo 拆出一个组件 return ( { // 如果点击的时候按下shift }} > {dataItem.getAllCells().map((cell) => ( ))} ); })}
    {flexRender( header.column.columnDef.header, header.getContext(), )}
    {flexRender(cell.column.columnDef.cell, cell.getContext())}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/download-view/index.scss ================================================ .download-view--container { & .header { font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 1.2rem; letter-spacing: 0.05rem; user-select: none; } } ================================================ FILE: src/renderer/pages/main-page/views/download-view/index.tsx ================================================ import { Tab } from "@headlessui/react"; import "./index.scss"; import Downloaded from "./components/Downloaded"; import Downloading from "./components/Downloading"; import { useTranslation } from "react-i18next"; export default function DownloadView() { const { t } = useTranslation(); return (
    {t("common.downloaded")} {t("common.downloading")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/index.scss ================================================ .local-music-view--container { display: flex; flex-direction: column; min-height: 100%; &[data-full-page="true"] { max-height: 100%; height: 100%; } & .header { font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 1.2rem; letter-spacing: 0.05rem; user-select: none; } & .operations { display: flex; align-items: center; justify-content: space-between; & .operations-layout { display: flex; align-items: center; & .search-local-music { margin-right: 12px; } & .list-view-action { width: 2.4rem; height: 2rem; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; & svg { width: 1.5rem; height: 1.5rem; } &:hover { background-color: var(--listHoverColor); color: var(--primaryColor); } &[data-selected="true"] { background-color: var(--listActiveColor); color: var(--primaryColor); } } } } } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/index.tsx ================================================ import localMusicListStore from "@/renderer/core/local-music/store"; import { useTranslation } from "react-i18next"; import "./index.scss"; import { showModal } from "@/renderer/components/Modal"; import SvgAsset from "@/renderer/components/SvgAsset"; import { useEffect, useState, useTransition } from "react"; import SwitchCase from "@/renderer/components/SwitchCase"; import ListView from "./views/list"; import ArtistView from "./views/artist"; import AlbumView from "./views/album"; import FolderView from "./views/folder"; import AppConfig from "@shared/app-config/renderer"; enum DisplayView { LIST, ARTIST, ALBUM, FOLDER, } export default function LocalMusicView() { const { t } = useTranslation(); const [displayView, setDisplayView] = useState(DisplayView.LIST); const localMusicList = localMusicListStore.useValue(); const [inputSearch, setInputSearch] = useState(""); const [filterMusicList, setFilterMusicList] = useState< IMusic.IMusicItem[] | null >(null); const [isPending, startTransition] = useTransition(); useEffect(() => { if (inputSearch.trim() === "") { setFilterMusicList(null); } else { startTransition(() => { const caseSensitive = AppConfig.getConfig( "playMusic.caseSensitiveInSearch", ); if (caseSensitive) { setFilterMusicList( localMusicListStore .getValue() .filter( (item) => item.title?.includes(inputSearch) || item.artist?.includes(inputSearch) || item.album?.includes(inputSearch), ), ); } else { const searchText = inputSearch.toLocaleLowerCase(); setFilterMusicList( localMusicListStore .getValue() .filter( (item) => item.title?.toLocaleLowerCase()?.includes(searchText) || item.artist?.toLocaleLowerCase()?.includes(searchText) || item.album?.toLocaleLowerCase()?.includes(searchText), ), ); } }); } }, [inputSearch]); const finalMusicList = filterMusicList ?? localMusicList; return (
    {t("local_music_page.local_music")}
    { showModal("WatchLocalDir"); }} > {t("local_music_page.auto_scan")}
    { setInputSearch(evt.target.value); }} placeholder={t("local_music_page.search_local_music")} >
    { setDisplayView(DisplayView.LIST); }} >
    { setDisplayView(DisplayView.ARTIST); }} >
    { setDisplayView(DisplayView.ALBUM); }} >
    { setDisplayView(DisplayView.FOLDER); }} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/album/index.scss ================================================ .local-music--album-view-container { flex: 1; overflow-y: auto; padding: 16px 0; display: flex; align-items: stretch; & .left-part { width: 150px; border-right: 1px solid var(--dividerColor); overflow-y: auto; flex-shrink: 0; & .album-item { height: 4rem; display: flex; flex-direction: column; justify-content: center; padding: 0 6px; cursor: pointer; user-select: none; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } & span:nth-child(2) { margin-top: 2px; font-size: 0.8rem; opacity: 0.7; } } } & .right-part { flex: 1; padding-left: 12px; overflow-y: auto; } } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/album/index.tsx ================================================ import localMusicListStore from "@/renderer/core/local-music/store"; import "./index.scss"; import { useMemo, useState } from "react"; import groupBy from "@/renderer/utils/groupBy"; import MusicList from "@/renderer/components/MusicList"; interface IProps { localMusicList: IMusic.IMusicItem[]; } export default function AlbumView(props: IProps) { const { localMusicList } = props; const [keys, allMusic] = useMemo(() => { const grouped = groupBy( localMusicList ?? [], (it) => `${it.album} - ${it.artist}`, ); return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped]; }, [localMusicList]); const [selectedKey, setSelectedKey] = useState(); const actualSelectedKey = selectedKey ?? keys?.[0]; return (
    {keys.map((it) => (
    { setSelectedKey(it); }} > {it.split(" - ")[0]} {it.split(" - ")[1]}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/artist/index.scss ================================================ .local-music--artist-view-container { flex: 1; overflow-y: auto; padding: 16px 0; display: flex; align-items: stretch; & .left-part { width: 150px; border-right: 1px solid var(--dividerColor); overflow-y: auto; flex-shrink: 0; & .artist-item { height: 4rem; display: flex; flex-direction: column; justify-content: center; padding: 0 6px; cursor: pointer; user-select: none; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } & span:nth-child(2) { margin-top: 2px; font-size: 0.8rem; opacity: 0.7; } } } & .right-part { flex: 1; padding-left: 12px; overflow-y: auto; } } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/artist/index.tsx ================================================ import localMusicListStore from "@/renderer/core/local-music/store"; import "./index.scss"; import { useMemo, useState } from "react"; import groupBy from "@/renderer/utils/groupBy"; import MusicList from "@/renderer/components/MusicList"; import { Trans } from "react-i18next"; interface IProps { localMusicList: IMusic.IMusicItem[]; } export default function ArtistView(props: IProps) { const { localMusicList } = props; const [keys, allMusic] = useMemo(() => { const grouped = groupBy(localMusicList ?? [], (it) => it.artist); return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped]; }, [localMusicList]); const [selectedKey, setSelectedKey] = useState(); const actualSelectedKey = selectedKey ?? keys?.[0]; return (
    {keys.map((it) => (
    { setSelectedKey(it); }} > {it}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/folder/index.scss ================================================ .local-music--folder-view-container { flex: 1; overflow-y: auto; padding: 16px 0; display: flex; align-items: stretch; & .left-part { width: 200px; border-right: 1px solid var(--dividerColor); overflow-y: auto; flex-shrink: 0; & .folder-item { height: 4rem; display: flex; flex-direction: column; justify-content: center; padding: 0 6px; cursor: pointer; user-select: none; & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } & span:nth-child(2) { margin-top: 2px; font-size: 0.8rem; opacity: 0.7; } } } & .right-part { flex: 1; padding-left: 12px; overflow-y: auto; } } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/folder/index.tsx ================================================ import localMusicListStore from "@/renderer/core/local-music/store"; import "./index.scss"; import { useMemo, useState } from "react"; import groupBy from "@/renderer/utils/groupBy"; import MusicList from "@/renderer/components/MusicList"; import { Trans } from "react-i18next"; interface IProps { localMusicList: IMusic.IMusicItem[]; } export default function FolderView(props: IProps) { const { localMusicList } = props; const [keys, allMusic] = useMemo(() => { const grouped = groupBy(localMusicList ?? [], (it) => window.path.dirname(it.$$localPath), ); return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped]; }, [localMusicList]); const [selectedKey, setSelectedKey] = useState(); const actualSelectedKey = selectedKey ?? keys?.[0]; return (
    {keys.map((it) => (
    { setSelectedKey(it); }} > {it}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/local-music-view/views/list/index.tsx ================================================ import MusicList from "@/renderer/components/MusicList"; import localMusicListStore from "@/renderer/core/local-music/store"; interface IProps { localMusicList: IMusic.IMusicItem[]; } export default function ListView(props: IProps) { const { localMusicList } = props; return ( ); } ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/index.scss ================================================ ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/index.tsx ================================================ import { useParams } from "react-router-dom"; import { localPluginName } from "@/common/constant"; import LocalSheet from "./local-sheet"; import RemoteSheet from "./remote-sheet"; import "./index.scss"; /** * path: /main/musicsheet/platform/id * * state: { * musicSheet: IMusic.MusicSheetItem * } * */ export default function MusicSheetView() { const { platform } = useParams() ?? {}; return (
    {platform === localPluginName ? ( ) : ( )}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/local-sheet/index.tsx ================================================ import { useParams } from "react-router-dom"; import MusicSheetlikeView from "@/renderer/components/MusicSheetlikeView"; import { RequestStateCode } from "@/common/constant"; import MusicSheet, { defaultSheet } from "@/renderer/core/music-sheet"; import { useTranslation } from "react-i18next"; export default function LocalSheet() { const { id } = useParams() ?? {}; const [musicSheet, loading] = MusicSheet.frontend.useMusicSheet(id); const { t } = useTranslation(); const _musicSheet = id === defaultSheet.id ? { ...musicSheet, title: t("media.default_favorite_sheet_name"), } : musicSheet; return ( ); } ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/remote-sheet/hooks/usePluginSheetMusicList.ts ================================================ import { RequestStateCode } from "@/common/constant"; import { isSameMedia } from "@/common/media-util"; import { useEffect, useRef, useState } from "react"; import PluginManager from "@shared/plugin-manager/renderer"; export default function usePluginSheetMusicList( platform: string, id: string, originalSheetItem?: IMusic.IMusicSheetItem | null, // 额外的输入 ) { const [requestState, setRequestState] = useState( RequestStateCode.IDLE, ); const [sheetItem, setSheetItem] = useState({ ...originalSheetItem, platform, id, }); const [musicList, setMusicList] = useState( originalSheetItem?.musicList ?? [], ); // 当前正在搜索的信息 const currentSheetItemRef = useRef(null); // 页码 const currentPageRef = useRef(1); const getSheetDetail = async () => { if (!isSameMedia(currentSheetItemRef.current, originalSheetItem)) { // 1.1 如果是切换了新的歌单 // 恢复初始状态 并设置当前的歌曲项 currentSheetItemRef.current = { ...originalSheetItem, platform, id, }; setSheetItem(currentSheetItemRef.current); setMusicList(originalSheetItem?.musicList ?? []); currentPageRef.current = 1; } else if (requestState & RequestStateCode.PENDING_FIRST_PAGE) { // 1.2 如果是原有歌单,并且在loading中,返回 return; } try { // 2. 设置初始状态 setRequestState( currentPageRef.current === 1 ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE, ); // 3. 调用获取音乐详情接口 const sheetItem = currentSheetItemRef.current; const result = await PluginManager.callPluginDelegateMethod( sheetItem, "getMusicSheetInfo", sheetItem, currentPageRef.current, ); if (!isSameMedia(currentSheetItemRef.current, sheetItem)) { // 出现竞态 结果直接舍弃 return; } if (result === null || result === undefined) { throw new Error(); } // 3. 如果在页码为1的时候返回了sheetItem,重新设置下sheetItem if (result?.sheetItem && currentPageRef.current <= 1) { setSheetItem((prev) => ({ ...(prev ?? {}), ...(result.sheetItem as IMusic.IMusicSheetItem), platform: originalSheetItem.platform, })); } // 4. 如果返回了音乐列表 if (result?.musicList) { setMusicList((prev) => { if (currentPageRef.current === 1) { return result?.musicList ?? prev; } else { return [...prev, ...(result.musicList ?? [])]; } }); } setRequestState( result.isEnd ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE, ); currentPageRef.current += 1; } catch { setRequestState( currentPageRef.current === 1 ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE, ); } }; useEffect(() => { if (platform && id) { getSheetDetail(); } }, [platform, id]); return [requestState, sheetItem, musicList, getSheetDetail] as const; } ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/remote-sheet/index.tsx ================================================ import React from "react"; import { useParams } from "react-router-dom"; import usePluginSheetMusicList from "./hooks/usePluginSheetMusicList"; import MusicSheetlikeView from "@/renderer/components/MusicSheetlikeView"; import { isSameMedia } from "@/common/media-util"; import MusicSheet from "@/renderer/core/music-sheet"; import { useTranslation } from "react-i18next"; import SvgAsset from "@/renderer/components/SvgAsset"; export default function RemoteSheet() { const { platform, id } = useParams() ?? {}; const [state, sheetItem, musicList, getSheetDetail] = usePluginSheetMusicList( platform, id, history.state?.usr?.sheetItem, ); return ( { getSheetDetail(); }} options={} /> ); } interface IProps { sheetItem: IMusic.IMusicSheetItem; } function RemoteSheetOptions(props: IProps) { const { sheetItem } = props; const starredMusicSheets = MusicSheet.frontend.useAllStarredSheets(); const { t } = useTranslation(); const isStarred = starredMusicSheets.find((item) => isSameMedia(sheetItem, item), ); return ( <>
    { if (isStarred) { MusicSheet.frontend.unstarMusicSheet(sheetItem); } else { MusicSheet.frontend.starMusicSheet(sheetItem); } }} > {t("music_sheet_like_view.star")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/music-sheet-view/store/musicSheetStore.ts ================================================ import Store from "@/common/store"; export default new Store(null); ================================================ FILE: src/renderer/pages/main-page/views/plugin-manager-view/components/plugin-table/index.scss ================================================ .plugin-table--container { width: calc(100% - 1rem); flex: 1; & .action-button { cursor: pointer; margin-right: 0.8rem; &:hover { font-weight: 500; color: var(--primaryColor) !important; border-bottom: 1px solid currentColor; } } & tr { position: relative; } } ================================================ FILE: src/renderer/pages/main-page/views/plugin-manager-view/components/plugin-table/index.tsx ================================================ import AppConfig from "@shared/app-config/renderer"; import { useReactTable, createColumnHelper, getCoreRowModel, flexRender, } from "@tanstack/react-table"; import "./index.scss"; import { CSSProperties, ReactNode } from "react"; import Condition, { IfTruthy } from "@/renderer/components/Condition"; import { hideModal, showModal } from "@/renderer/components/Modal"; import Empty from "@/renderer/components/Empty"; import { toast } from "react-toastify"; import { showPanel } from "@/renderer/components/Panel"; import DragReceiver, { startDrag } from "@/renderer/components/DragReceiver"; import { produce } from "immer"; import { i18n } from "@/shared/i18n/renderer"; import PluginManager, { useSortedPlugins } from "@shared/plugin-manager/renderer"; const t = i18n.t; function renderOptions(info: any) { const row = info.row.original as IPlugin.IPluginDelegate; return (
    { showModal("Reconfirm", { title: t("plugin_management_page.uninstall_plugin"), content: t("plugin_management_page.confirm_text_uninstall_plugin", { plugin: row.platform, }), async onConfirm() { hideModal(); try { await PluginManager.uninstallPlugin(row.hash); toast.success( t("plugin_management_page.uninstall_successfully", { plugin: row.platform, }), ); } catch { toast.error(t("plugin_management_page.uninstall_failed")); } }, }); }} > {t("plugin_management_page.uninstall")} { try { await PluginManager.installPluginFromRemote(row.srcUrl); toast.success( t("plugin_management_page.toast_plugin_is_latest", { plugin: row.platform, }), ); } catch (e) { toast.error( e?.message ?? t("plugin_management_page.update_failed"), ); } }} > {t("plugin_management_page.update")} { showModal("SimpleInputWithState", { title: t("plugin.method_import_music_item"), withLoading: true, loadingText: t("plugin_management_page.importing_media"), placeholder: t( "plugin_management_page.placeholder_import_music_item", { plugin: row.platform, }, ), maxLength: 1000, onOk(text) { return PluginManager.callPluginDelegateMethod( row, "importMusicItem", text.trim(), ); }, onPromiseResolved(result) { hideModal(); showModal("AddMusicToSheet", { musicItems: result as IMusic.IMusicItem[], }); }, onPromiseRejected() { console.log(t("plugin_management_page.import_failed")); }, hints: row.hints?.importMusicItem, }); }} > {t("plugin.method_import_music_item")} { showModal("SimpleInputWithState", { title: t("plugin.method_import_music_sheet"), withLoading: true, loadingText: t("plugin_management_page.importing_media"), placeholder: t( "plugin_management_page.placeholder_import_music_sheet", { plugin: row.platform, }, ), maxLength: 1000, onOk(text) { return PluginManager.callPluginDelegateMethod( row, "importMusicSheet", text.trim(), ); }, onPromiseResolved(result) { hideModal(); showModal("AddMusicToSheet", { musicItems: result as IMusic.IMusicItem[], }); }, onPromiseRejected() { toast.error(t("plugin_management_page.import_failed")); }, hints: row.hints?.importMusicSheet, }); }} > {t("plugin.method_import_music_sheet")} { showPanel("UserVariables", { variables: row.userVariables, plugin: row, initValues: AppConfig.getConfig("private.pluginMeta")?.[row.platform] ?.userVariables, }); }} > {t("plugin.prop_user_variable")}
    ); } const columnHelper = createColumnHelper(); const columnDef = [ columnHelper.accessor((_, index) => index + 1, { id: "id", cell(info) { return info.getValue(); }, header: () => "#", minSize: 64, maxSize: 64, size: 64, }), columnHelper.accessor("platform", { cell: (info) => info.getValue(), header: () => t("media.media_platform"), minSize: 150, size: 200, }), columnHelper.accessor("version", { cell: (info) => info.getValue(), header: () => t("common.version_code"), minSize: 100, maxSize: 100, size: 100, }), columnHelper.accessor("author", { cell: (info) => info.getValue() ?? t("media.unknown_artist"), header: () => t("media.media_type_artist"), maxSize: 100, minSize: 100, size: 100, }), columnHelper.accessor(() => 0, { id: "extra", cell: renderOptions, header: () => t("common.operation"), }), ]; export default function PluginTable() { const plugins = useSortedPlugins(); const table = useReactTable({ data: plugins, columns: columnDef, getCoreRowModel: getCoreRowModel(), }); function onDrop(fromIndex: number, toIndex: number) { const meta = AppConfig.getConfig("private.pluginMeta") ?? {}; const newPlugins = plugins .slice(0, fromIndex) .concat(plugins.slice(fromIndex + 1)); newPlugins.splice( fromIndex < toIndex ? toIndex - 1 : toIndex, 0, plugins[fromIndex], ); const newMeta = produce(meta, (draft) => { newPlugins.forEach((plugin, index) => { if (!draft[plugin.platform]) { draft[plugin.platform] = {}; } draft[plugin.platform].order = index; }); }); AppConfig.setConfig({ "private.pluginMeta": newMeta, }); } return (
    } > {table.getHeaderGroups()[0].headers.map((header) => ( ))} {table.getRowModel().rows.map((row, index) => ( { startDrag(e, index); }} > {row.getAllCells().map((cell) => ( ))} ))}
    {flexRender( header.column.columnDef.header, header.getContext(), )}
    {flexRender(cell.column.columnDef.cell, cell.getContext())}
    ); } interface IActionButtonProps { children: ReactNode; onClick?: () => void; style?: CSSProperties; } function ActionButton(props: IActionButtonProps) { const { children, onClick, style } = props; return ( {children} ); } ================================================ FILE: src/renderer/pages/main-page/views/plugin-manager-view/index.scss ================================================ .plugin-manager-view-container { width: 100%; height: 100%; display: flex; flex-direction: column; box-sizing: border-box; & .header { font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 1.2rem; letter-spacing: 0.05rem; user-select: none; } & .operation-area { margin-bottom: 1.5rem; display: flex; align-items: center; justify-content: space-between; & .left-part, & .right-part { display: flex; gap: 12px; } } } ================================================ FILE: src/renderer/pages/main-page/views/plugin-manager-view/index.tsx ================================================ import { hideModal, showModal } from "@/renderer/components/Modal"; import PluginTable from "./components/plugin-table"; import "./index.scss"; import { getUserPreference } from "@/renderer/utils/user-perference"; import { toast } from "react-toastify"; import A from "@/renderer/components/A"; import { Trans, useTranslation } from "react-i18next"; import { dialogUtil } from "@shared/utils/renderer"; import PluginManager from "@shared/plugin-manager/renderer"; export default function PluginManagerView() { const { t } = useTranslation(); return (
    {t("plugin_management_page.plugin_management")}
    { try { const result = await dialogUtil.showOpenDialog({ title: t("plugin_management_page.choose_plugin"), buttonLabel: t("plugin_management_page.install"), filters: [ { extensions: ["js", "json"], name: t("plugin_management_page.musicfree_plugin"), }, ], }); if (result.canceled) { return; } await PluginManager.installPluginFromLocal(result.filePaths[0]); toast.success(t("plugin_management_page.install_successfully")); } catch (e) { toast.warn( `${t("plugin_management_page.install_failed")}: ${ e.message ?? t("plugin_management_page.invalid_plugin") }`, ); } }} > {t("plugin_management_page.install_from_local_file")}
    { showModal("SimpleInputWithState", { title: t("plugin_management_page.install_plugin_from_network"), placeholder: t( "plugin_management_page.error_hint_plugin_should_end_with_js_or_json", ), okText: t("plugin_management_page.install"), loadingText: t("plugin_management_page.installing"), withLoading: true, async onOk(text) { if ( text.trim().endsWith(".json") || text.trim().endsWith(".js") ) { return PluginManager.installPluginFromRemote(text); } else { throw new Error( t( "plugin_management_page.error_hint_plugin_should_end_with_js_or_json", ), ); } }, onPromiseResolved() { toast.success( t("plugin_management_page.install_successfully"), ); hideModal(); }, onPromiseRejected(e) { toast.warn( `${t("plugin_management_page.install_failed")}: ${ e.message ?? t("plugin_management_page.invalid_plugin") }`, ); }, hints: [ , }} >, ], }); }} > {t("plugin_management_page.install_plugin_from_network")}
    {/*
    { showModal("SimpleInputWithState", { title: "从网络安装插件", placeholder: "请输入插件源地址(链接以json或js结尾)", okText: "安装", loadingText: "安装中", withLoading: true, async onOk(text) { if ( text.trim().endsWith(".json") || text.trim().endsWith(".js") ) { return ipcRendererInvoke("install-plugin-remote", text); } else { throw new Error("插件链接需要以json或者js结尾"); } }, onPromiseResolved() { toast.success("安装成功~"); hideModal(); }, onPromiseRejected(e) { toast.warn(`安装失败: ${e.message ?? "无效插件"}`); }, hints: [ "插件需要满足 MusicFree 特定的插件协议,具体可在官方网站中查看", ], }); }} > 一键更新
    */}
    { showModal("PluginSubscription"); }} > {t("plugin_management_page.subscription_setting")}
    { const subscription = getUserPreference("subscription"); if (subscription?.length) { for (let i = 0; i < subscription.length; ++i) { await PluginManager.installPluginFromRemote(subscription[i].srcUrl); } toast.success(t("plugin_management_page.update_successfully")); } else { toast.warn(t("plugin_management_page.no_subscription")); } }} > {t("plugin_management_page.update_subscription")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/recently-play-view/index.tsx ================================================ import MusicSheetlikeView from "@/renderer/components/MusicSheetlikeView"; import SvgAsset from "@/renderer/components/SvgAsset"; import { clearRecentlyPlaylist, useRecentlyPlaylistSheet, } from "@/renderer/core/recently-playlist"; import { useTranslation } from "react-i18next"; export default function RecentlyPlayView() { const recentlyPlaylistSheet = useRecentlyPlaylistSheet(); const { t } = useTranslation(); const options = ( <>
    { clearRecentlyPlaylist(); }} > {t("common.clear")}
    ); console.log(recentlyPlaylistSheet); return (
    ); } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/index.scss ================================================ .recommend-sheet-view--body-container { & .tags-container { margin-top: 16px; position: relative; display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; $tag-size: 4px; & .first-tag { flex-shrink: 0; font-size: 1rem; display: flex; align-items: center; &::after { content: ""; display: block; width: 0; height: 0; margin-left: 0.5rem; border: $tag-size solid transparent; border-left-color: currentColor; transform-origin: left center; transition: transform linear 100ms; } &[data-panel-open="true"] { &::after { transform: rotate(90deg); } } } & .pinned-tag { font-size: 1rem; opacity: 0.8; white-space: nowrap; cursor: pointer; } } & .list-container{ margin-top: 16px; min-height: 300px; height: 300px; } } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/index.tsx ================================================ import { useEffect, useState } from "react"; import "./index.scss"; import classNames from "@/renderer/utils/classnames"; import useRecommendListTags from "../../hooks/useRecommendListTags"; import TagPanel from "./tag-panel"; import useRecommendSheets from "../../hooks/useRecommendSheets"; import MusicSheetlikeList from "@/renderer/components/MusicSheetlikeList"; import Condition from "@/renderer/components/Condition"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; import { useNavigate } from "react-router-dom"; import { i18n } from "@/shared/i18n/renderer"; export function getDefaultTag(): IMedia.IUnique { return { title: i18n.t("common.default"), id: "", }; } interface IBodyProps { plugin: IPlugin.IPluginDelegate; } export default function Body(props: IBodyProps) { const { plugin } = props; // 选中的tag const [selectedTag, setSelectedTag] = useState(null); // 第一个tag const [firstTag, setFirstTag] = useState(getDefaultTag); const tags = useRecommendListTags(plugin); // const tags: any[] = []; const [showPanel, setShowPanel] = useState(false); const [query, sheets, status] = useRecommendSheets(plugin, selectedTag); const navigate = useNavigate(); useEffect(() => { if (tags) { const cachedTag = history.state?.usr?.tag; if (cachedTag) { if (tags.pinned?.findIndex?.((it) => it.id === cachedTag.id) === -1) { setFirstTag(cachedTag); } setSelectedTag(cachedTag); } else { setSelectedTag(getDefaultTag); } } }, [tags]); return (
    { setSelectedTag(tag); setFirstTag(tag); const usr = history.state.usr ?? {}; navigate("", { replace: true, state: { ...usr, tag: tag, }, }); setShowPanel(false); }} >
    { setShowPanel((prev) => !prev); }} > {firstTag.title}
    {tags?.pinned?.map?.((tag) => (
    { setSelectedTag(tag); const usr = history.state.usr ?? {}; navigate("", { replace: true, state: { ...usr, tag: tag, }, }); }} > {tag.title}
    ))}
    } > { query(); }} onClick={(sheetItem) => { navigate( `/main/musicsheet/${encodeURIComponent(sheetItem.platform)}/${encodeURIComponent(sheetItem.id)}`, { state: { sheetItem: sheetItem, }, }, ); }} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/tag-panel.scss ================================================ .tag-panel--container { position: absolute; z-index: 20; width: 560px; max-height: 360px; padding: 14px; box-sizing: border-box; overflow-y: auto; top: calc(1rem + 25px); left: 0; transition: transform 100ms ease-out; transform-origin: center top; &[data-show="false"] { transform: scaleY(0); pointer-events: none; user-select: none; } &[data-show="true"] { transform: scaleY(1); pointer-events: all; } & .tag-group--tag { font-size: 1rem !important; color: #666; white-space: nowrap; cursor: pointer; } & .tag-group--container { margin-bottom: 16px; & .tag-group--title { margin-bottom: 12px; opacity: 0.8; } & .tag-group--tags { display: flex; flex-wrap: wrap; gap: 10px 14px; } } } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/tag-panel.tsx ================================================ import Condition from "@/renderer/components/Condition"; import { getDefaultTag } from "."; import "./tag-panel.scss"; interface ITagPanelProps { show: boolean; tagsGroups: IMusic.IMusicSheetGroupItem[]; onTagClick?: (tag: IMedia.IUnique) => void; } export default function TagPanel(props: ITagPanelProps) { const { show, onTagClick, tagsGroups } = props; const defaultTag = getDefaultTag(); return (
    { onTagClick?.(defaultTag); }} > {defaultTag.title}
    {tagsGroups?.map?.((tagGroup, index) => (
    {tagGroup.title}
    {tagGroup.data.map((tag) => (
    { onTagClick?.(tag); }} > {tag.title}
    ))}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/hooks/useRecommendListTags.ts ================================================ import { useCallback, useEffect, useState } from "react"; import PluginManager from "@shared/plugin-manager/renderer"; export default function (plugin: IPlugin.IPluginDelegate) { const [tags, setTags] = useState( null, ); const query = useCallback(async () => { try { const result = await PluginManager.callPluginDelegateMethod( plugin, "getRecommendSheetTags", ); if (!result) { throw new Error(); } setTags(result); } catch { setTags({ pinned: [], data: [], }); } }, []); useEffect(() => { query(); }, []); return tags; } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/hooks/useRecommendSheets.ts ================================================ import { RequestStateCode } from "@/common/constant"; import { resetMediaItem } from "@/common/media-util"; import { useCallback, useEffect, useRef, useState } from "react"; import PluginManager from "@shared/plugin-manager/renderer"; export default function (plugin: IPlugin.IPluginDelegate, tag: IMedia.IUnique | null) { const [sheets, setSheets] = useState([]); const [status, setStatus] = useState(RequestStateCode.IDLE); const currentTagRef = useRef(); const pageRef = useRef(0); const query = useCallback(async () => { if ( (RequestStateCode.PENDING_FIRST_PAGE & status || RequestStateCode.FINISHED === status) && currentTagRef.current === tag.id ) { return; } if (currentTagRef.current !== tag.id) { setSheets([]); pageRef.current = 0; } pageRef.current++; currentTagRef.current = tag.id; setStatus( pageRef.current === 1 ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE, ); const res = await PluginManager.callPluginDelegateMethod( plugin, "getRecommendSheetsByTag", tag, pageRef.current, ); if (tag.id === currentTagRef.current) { setSheets((prev) => [ ...prev, ...res.data!.map((item) => resetMediaItem(item, plugin.platform)), ]); } if (res.isEnd) { setStatus(RequestStateCode.FINISHED); } else { setStatus(RequestStateCode.PARTLY_DONE); } }, [tag, status]); useEffect(() => { if (tag) { query(); } }, [tag]); return [query, sheets, status] as const; } ================================================ FILE: src/renderer/pages/main-page/views/recommend-sheets-view/index.tsx ================================================ import Condition from "@/renderer/components/Condition"; import NoPlugin from "@/renderer/components/NoPlugin"; import { Tab } from "@headlessui/react"; import { useNavigate } from "react-router-dom"; import Body from "./components/Body"; import PluginManager from "@shared/plugin-manager/renderer"; export default function RecommendSheetsView() { const availablePlugins = PluginManager.getSortedSupportedPlugin("getRecommendSheetsByTag"); const navigate = useNavigate(); return (
    } > { const usr = history.state.usr ?? {}; navigate("", { replace: true, state: { ...usr, pluginHash: availablePlugins[index].hash, pluginIndex: index, tag: null, }, }); }} > {availablePlugins.map((plugin) => ( {plugin.platform} ))} {availablePlugins.map((plugin) => ( ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/AlbumResult/index.scss ================================================ .search-result--album-result-container { width: 100%; & .result-body { display: grid; width: 100%; grid-template-columns: repeat(5, 1fr); } } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/AlbumResult/index.tsx ================================================ import { RequestStateCode } from "@/common/constant"; import React, { memo } from "react"; import "./index.scss"; import useSearch from "../../../hooks/useSearch"; import { useNavigate } from "react-router-dom"; import MusicSheetlikeList from "@/renderer/components/MusicSheetlikeList"; interface IMediaResultProps { data: IAlbum.IAlbumItem[]; state: RequestStateCode; pluginHash: string; } function AlbumResult(props: IMediaResultProps) { const { data, state, pluginHash } = props; const search = useSearch(); const navigate = useNavigate(); return ( { search(undefined, undefined, "album", pluginHash); }} onClick={(albumItem) => { navigate(`/main/album/${encodeURIComponent(albumItem.platform)}/${encodeURIComponent(albumItem.id)}`, { state: { albumItem, }, }); }} > ); } export default memo( AlbumResult, (prev, curr) => prev.data === curr.data && prev.state === curr.state && prev.pluginHash === curr.pluginHash, ); ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/ArtistResult/index.scss ================================================ .search-result--artist-result-container { width: 100%; & .result-body { display: grid; width: 100%; grid-template-columns: repeat(5, 1fr); } } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/ArtistResult/index.tsx ================================================ import { RequestStateCode } from "@/common/constant"; import BottomLoadingState from "@/renderer/components/BottomLoadingState"; import useSearch from "../../../hooks/useSearch"; import ArtistItem from "@/renderer/components/ArtistItem"; import "./index.scss"; import { useNavigate } from "react-router-dom"; interface IMediaResultProps { data: IArtist.IArtistItem[]; state: RequestStateCode; pluginHash: string; } export default function ArtistResult(props: IMediaResultProps) { const { data, state, pluginHash } = props; const search = useSearch(); const navigate = useNavigate(); return (
    {data.map((artistItem, index) => { return { navigate( `/main/artist/${encodeURIComponent(artistItem.platform)}/${encodeURIComponent(artistItem.id)}`, { state: { artistItem, }, }, ); }}>; })}
    { search(undefined, undefined, "artist", pluginHash); }} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/MusicResult/index.tsx ================================================ import React, { memo } from "react"; import MusicList from "@/renderer/components/MusicList"; import { RequestStateCode } from "@/common/constant"; import useSearch from "../../../hooks/useSearch"; interface IMediaResultProps { data: IMusic.IMusicItem[]; state: RequestStateCode; pluginHash: string; } function MusicResult(props: IMediaResultProps) { const { data, state, pluginHash } = props; const search = useSearch(); return ( { search(undefined, undefined, "music", pluginHash); }} virtualProps={{ fallbackRenderCount: -1, }} > ); } export default memo( MusicResult, (prev, curr) => prev.data === curr.data && prev.state === curr.state && prev.pluginHash === curr.pluginHash, ); ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/SheetResult/index.scss ================================================ .search-result--album-result-container { width: 100%; & .result-body { display: grid; width: 100%; grid-template-columns: repeat(5, 1fr); } } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/SheetResult/index.tsx ================================================ import { RequestStateCode } from "@/common/constant"; import { memo } from "react"; import "./index.scss"; import useSearch from "../../../hooks/useSearch"; import { useNavigate } from "react-router-dom"; import MusicSheetlikeList from "@/renderer/components/MusicSheetlikeList"; interface IMediaResultProps { data: IAlbum.IAlbumItem[]; state: RequestStateCode; pluginHash: string; } function SheetResult(props: IMediaResultProps) { const { data, state, pluginHash } = props; const search = useSearch(); const navigate = useNavigate(); return ( { search(undefined, undefined, "sheet", pluginHash); }} onClick={(sheetItem) => { navigate(`/main/musicsheet/${encodeURIComponent(sheetItem.platform)}/${encodeURIComponent(sheetItem.id)}`, { state: { sheetItem, }, }); }} > ); } export default memo( SheetResult, (prev, curr) => prev.data === curr.data && prev.state === curr.state && prev.pluginHash === curr.pluginHash, ); ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/index.scss ================================================ .search-view--plugins { display: flex; flex-wrap: wrap; margin-top: 12px; margin-bottom: 12px; font-size: 1.1rem; gap: 8px 14px; & .plugin-item { box-sizing: border-box; height: 2rem; border-radius: 1rem; padding: 2px 8px; border: 1px solid currentColor; display: flex; align-items: center; justify-content: center; &[data-selected="true"] { border: none; background-color: var(--primaryColor); color: white; } } } ================================================ FILE: src/renderer/pages/main-page/views/search-view/components/SearchResult/index.tsx ================================================ import { useEffect, useState, memo } from "react"; import "./index.scss"; import Condition from "@/renderer/components/Condition"; import AlbumResult from "./AlbumResult"; import MusicResult from "./MusicResult"; import ArtistResult from "./ArtistResult"; import { searchResultsStore } from "../../store/search-result"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; import useSearch from "../../hooks/useSearch"; import SwitchCase from "@/renderer/components/SwitchCase"; import { useNavigate } from "react-router-dom"; import SheetResult from "./SheetResult"; interface ISearchResultProps { type: IMedia.SupportMediaType; query: string; plugins: IPlugin.IPluginDelegate[]; } export default function SearchResult(props: ISearchResultProps) { const { type, plugins, query } = props; const [selectedPlugin, setSelectedPlugin] = useState( history.state?.usr?.plugin ?? null, ); useEffect(() => { if (plugins.length && !selectedPlugin) { setSelectedPlugin(plugins[0]); } }, [plugins, selectedPlugin]); const navigate = useNavigate(); return ( <>
    {plugins?.map?.((plugin) => (
    { setSelectedPlugin(plugin); const usr = history.state.usr ?? {}; // 获取history navigate("", { replace: true, state: { ...usr, plugin: plugin, }, }); }} data-selected={selectedPlugin?.hash === plugin.hash} > {plugin.platform}
    ))}
    ); } interface ISearchResultBodyProps { type: IMedia.SupportMediaType; pluginHash: string; query: string; } function _SearchResultBody(props: ISearchResultBodyProps) { const { type, pluginHash, query } = props; const searchResults = searchResultsStore.useValue(); const currentResult = searchResults[type][pluginHash]; const data = currentResult?.data ?? ([] as any[]); const search = useSearch(); useEffect(() => { if (pluginHash && type && query) { search(query, 1, type, pluginHash); } }, [pluginHash, type, query]); return ( <> } > ); } const SearchResultBody = memo( _SearchResultBody, (prev, curr) => prev.pluginHash === curr.pluginHash && prev.type === curr.type, ); ================================================ FILE: src/renderer/pages/main-page/views/search-view/hooks/useSearch.ts ================================================ import { produce } from "immer"; import { useCallback, useRef } from "react"; import { searchResultsStore } from "../store/search-result"; import { RequestStateCode } from "@/common/constant"; import PluginManager from "@shared/plugin-manager/renderer"; export default function useSearch() { const searchResults = searchResultsStore.getValue(); const setSearchResults = searchResultsStore.setValue; // 当前正在搜索 const currentQueryRef = useRef(""); /** * query: 搜索词 * queryPage: 搜索页码 * type: 搜索类型 * pluginHash: 搜索条件 */ const search = useCallback( async function ( query?: string, queryPage?: number, type?: IMedia.SupportMediaType, pluginHash?: string, ) { /** 如果没有指定插件,就用所有插件搜索 */ let pluginDelegates: IPlugin.IPluginDelegate[] = []; if (pluginHash) { const tgtPlugin = PluginManager.getPluginByHash(pluginHash); if (tgtPlugin) { pluginDelegates = [tgtPlugin]; } } else { pluginDelegates = PluginManager.getSupportedPlugin("search"); } // 使用选中插件搜素 pluginDelegates.forEach(async (pluginDelegate) => { const _platform = pluginDelegate.platform; const _hash = pluginDelegate.hash; if (!_platform || !_hash) { // 插件无效 return; } const searchType = type ?? pluginDelegate.defaultSearchType ?? "music"; console.log("Search: ", query, searchType, _platform); // 上一份搜索结果 const prevPluginResult = searchResults[searchType][pluginDelegate.hash]; /** 上一份搜索还没返回/已经结束 */ if ( (prevPluginResult?.state === RequestStateCode.PENDING_REST_PAGE || prevPluginResult?.state === RequestStateCode.FINISHED) && undefined === query ) { return; } // 是否是一次新的搜索 const newSearch = (query !== undefined && query !== prevPluginResult?.query) || prevPluginResult?.page === undefined; // 本次搜索关键词 currentQueryRef.current = query = query ?? prevPluginResult?.query ?? ""; /** 搜索的页码 */ const page = queryPage ?? newSearch ? 1 : (prevPluginResult?.page ?? 0) + 1; if ( query === prevPluginResult?.query && queryPage <= prevPluginResult?.page ) { // 重复请求 return; } try { setSearchResults( produce((draft) => { const prevMediaResult: any = draft[searchType]; prevMediaResult[_hash] = { state: newSearch ? RequestStateCode.PENDING_FIRST_PAGE : RequestStateCode.PENDING_REST_PAGE, // @ts-ignore data: newSearch ? [] : prevMediaResult[_hash]?.data ?? [], query: query, page, }; }), ); const result = await PluginManager.callPluginDelegateMethod( pluginDelegate, "search", query, page, searchType, ); console.log( "SEARCH", result, query, page, searchType, pluginDelegate.platform, ); /** 如果搜索结果不是本次结果 */ if (currentQueryRef.current !== query) { return; } if (!result) { throw new Error("搜索结果为空"); } setSearchResults( produce((draft) => { const prevMediaResult = draft[searchType]; const prevPluginResult: any = prevMediaResult[_hash] ?? { data: [], }; const currResult = result.data ?? []; prevMediaResult[_hash] = { state: result?.isEnd === false && result?.data?.length ? RequestStateCode.PARTLY_DONE : RequestStateCode.FINISHED, query, page, data: newSearch ? currResult : (prevPluginResult.data ?? []).concat(currResult), }; return draft; }), ); } catch (e: any) { setSearchResults( produce((draft) => { const prevMediaResult = draft[searchType]; const prevPluginResult = prevMediaResult[_hash] ?? ({ data: [] as any[], } as any); prevPluginResult.state = page === 1 ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE; return draft; }), ); } }); }, [searchResults], ); return search; } ================================================ FILE: src/renderer/pages/main-page/views/search-view/index.scss ================================================ .search-view-container { width: 100%; height: 100%; padding-top: 1rem; user-select: none; display: flex; flex-direction: column; box-sizing: border-box; & .search-header{ font-size: 1.4rem; font-weight: 600; flex-shrink: 0; } } ================================================ FILE: src/renderer/pages/main-page/views/search-view/index.tsx ================================================ import { useEffect } from "react"; import { useMatch, useNavigate } from "react-router-dom"; import "./index.scss"; import NoPlugin from "@/renderer/components/NoPlugin"; import { Tab } from "@headlessui/react"; import { supportedMediaType } from "@/common/constant"; import { useTranslation } from "react-i18next"; import SearchResult from "./components/SearchResult"; import useSearch from "./hooks/useSearch"; import { currentMediaTypeStore, resetStore } from "./store/search-result"; import PluginManager, { useSortedSupportedPlugin } from "@shared/plugin-manager/renderer"; export default function SearchView() { const match = useMatch("/main/search/:query"); const query = decodeURIComponent(match?.params?.query ?? ""); const plugins = useSortedSupportedPlugin("search"); const { t } = useTranslation(); const search = useSearch(); const navigate = useNavigate(); useEffect(() => { if (query) { const currentType = currentMediaTypeStore.getValue(); search(query, 1, currentType); } }, [query]); useEffect(() => { return () => { resetStore(); }; }, []); return (
    「{decodeURIComponent(query)}」 {t("search_result_page.search_result_title")}
    {plugins.length ? ( { currentMediaTypeStore.setValue(supportedMediaType[index]); // 获取history navigate("", { replace: true, state: { mediaIndex: index, }, }); }} > {supportedMediaType.map((type) => ( {t(`media.media_type_${type}`)} ))} {supportedMediaType.map((type) => ( ))} ) : ( )}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/search-view/store/search-result.ts ================================================ /** 搜索状态 */ import { RequestStateCode } from "@/common/constant"; import Store from "@/common/store"; export interface ISearchResult { /** 当前页码 */ page?: number; /** 搜索词 */ query?: string; /** 搜索状态 */ state: RequestStateCode; /** 数据 */ data: IMedia.SupportMediaItem[T][]; } type ISearchResults< T extends keyof IMedia.SupportMediaItem = IMedia.SupportMediaType, > = { [K in T]: Record>; }; /** 初始值 */ export const initSearchResults: ISearchResults = { music: {}, album: {}, artist: {}, sheet: {}, lyric: {}, }; /** key: pluginhash value: searchResult */ const searchResultsStore = new Store(initSearchResults); const currentMediaTypeStore = new Store("music"); export { searchResultsStore, currentMediaTypeStore }; export function resetStore(){ currentMediaTypeStore.setValue("music"); searchResultsStore.setValue(initSearchResults); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/CheckBoxSettingItem/index.tsx ================================================ import SvgAsset from "@/renderer/components/SvgAsset"; import classNames from "@/renderer/utils/classnames"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; import AppConfig from "@shared/app-config/renderer"; interface ICheckBoxSettingItemProps { keyPath: T; label?: string; onChange?: (event: Event, checked: boolean) => void; } export default function CheckBoxSettingItem( props: ICheckBoxSettingItemProps, ) { const { keyPath, label, onChange, } = props; const checked = useAppConfig(keyPath); return (
    { const event = new Event("ConfigChanged", { cancelable: true, }); if (onChange) { onChange(event, !checked); } if (!event.defaultPrevented) { AppConfig.setConfig({ [keyPath]: !checked, }); } }} >
    {checked ? : null}
    {label}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/ColorPickerSettingItem/index.scss ================================================ .picker-container { margin-top: 0.6rem; display: flex; align-items: center; column-gap: 1rem; & .picker-swatch { width: 1rem; height: 1rem; border-radius: 2px; border: 1px solid var(--textColor); cursor: pointer; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(0, 0, 0, 0.1); font-size: 1.1rem; } } .setting-colorpicker-panel { position: absolute; z-index: 10; & .react-colorful__pointer { width: 14px; height: 14px; } & .react-colorful__hue, & .react-colorful__alpha { height: 18px; border-radius: 0; } & .setting-colorpicker-options { width: 200px; display: flex; & input { letter-spacing: 1px; width: 136px; background: var(--backdropColor, var(--backgroundColor)) } & div[role="button"] { width: 64px; display: flex; align-items: center; justify-content: center; background-color: var(--primaryColor); color: white; } } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/ColorPickerSettingItem/index.tsx ================================================ import { Popover } from "@headlessui/react"; import "./index.scss"; import { useState } from "react"; import { HexAlphaColorPicker, HexColorInput } from "react-colorful"; import useAppConfig from "@/hooks/useAppConfig"; import { IAppConfig } from "@/types/app-config"; import AppConfig from "@shared/app-config/renderer"; interface IColorPickerSettingItemProps { keyPath: T; label?: string; } export default function ColorPickerSettingItem( props: IColorPickerSettingItemProps, ) { const { keyPath, label } = props; const realColor = useAppConfig(keyPath); const [color, setColor] = useState(realColor as string); return (
    {label}
    {realColor as string}
    {({ close }) => { return ( <>
    { AppConfig.setConfig({ [keyPath]: color as any, }); close(); }} > 提交
    ); }}
    //
    { // setAppConfigPath(keyPath, !checked as any); // }} // > //
    //
    // {checked ? : null} //
    // {label} //
    //
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/FontPickerSettingItem/index.scss ================================================ .setting-view--list-box-setting-item-container { & .options-container { margin-top: 0.6rem; display: flex; gap: 0.5rem 3rem; position: relative; user-select: none; & .listbox-button { height: 2.6rem; width: 200px; position: relative; display: flex; align-items: center; padding-left: 12px; border-radius: 6px; background-color: var(--placeholderColor); border: 1px solid var(--dividerColor); &::after { position: absolute; right: 6px; content: ""; width: 0; height: 0; margin-left: 0.5rem; border: 4px solid transparent; border-left-color: currentColor; transform-origin: left center; transform: rotate(90deg); } } & .listbox-options { position: absolute; padding-inline-start: 0; margin-block-start: 0; border-radius: 4px; left: 0; top: 3rem; z-index: 10; width: 212px; height: 280px; overflow-y: auto; list-style: none; & .listbox-option { list-style: none; height: 2.2rem; line-height: 2.2rem; padding-left: 12px; vertical-align: middle; cursor: default; &[data-headlessui-state*="active"] { color: var(--primaryColor); } } &:focus { outline: none; } } } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/FontPickerSettingItem/index.tsx ================================================ import { useMemo } from "react"; import ListBoxSettingItem from "../ListBoxSettingItem"; import { defaultFont as _defaultFont } from "@/common/constant"; import useLocalFonts from "@/hooks/useLocalFonts"; import { useTranslation } from "react-i18next"; import { IAppConfig } from "@/types/app-config"; import AppConfig from "@shared/app-config/renderer"; interface FontPickerSettingItemProps { keyPath: T; label?: string; } function useFonts() { const allLocalFonts = useLocalFonts(); const { t } = useTranslation(); const defaultFont = { ..._defaultFont, fullName: t("common.default"), }; const fonts = useMemo( () => (allLocalFonts ? [defaultFont, ...allLocalFonts] : null), [allLocalFonts], ); return fonts; } export default function FontPickerSettingItem( props: FontPickerSettingItemProps, ) { const { keyPath, label } = props; const fonts = useFonts(); return ( (item as FontData).fullName} options={fonts ?? (null as any)} onChange={(event, newValue) => { // 字体不可序列化 不知道为啥 json.stringify是空对象 event.preventDefault(); console.log(event.defaultPrevented, "Prev"); AppConfig.setConfig({ [keyPath]: { family: (newValue as FontData).family, fullName: (newValue as FontData).fullName, postscriptName: (newValue as FontData).postscriptName, style: (newValue as FontData).style, }, }); }} > ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/InputSettingItem/index.scss ================================================ .setting-view--input-setting-item-container { display: flex; align-items: center; width: 200px; column-gap: 12px; & .input-label { width: 48px; white-space: nowrap; } & input { flex: 1; } & input:disabled { opacity: 0.5; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/InputSettingItem/index.tsx ================================================ import AppConfig from "@shared/app-config/renderer"; import "./index.scss"; import { HTMLInputTypeAttribute, useState } from "react"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; interface InputSettingItemProps { keyPath: T; label?: string; onChange?: (event: Event, val: IAppConfig[T]) => void; width?: number | string; /** 是否过滤首尾空格 */ trim?: boolean; disabled?: boolean; type?: HTMLInputTypeAttribute; } export default function InputSettingItem( props: InputSettingItemProps, ) { const { keyPath, label, onChange, width, type, disabled, trim, } = props; const value = useAppConfig(keyPath); const [tmpValue, setTmpValue] = useState(value as string || ""); return (
    {label ?
    {label}
    : null} { setTmpValue(e.target.value ?? null); }} type={type} onBlur={() => { if (tmpValue === null) { return; } const event = new Event("ConfigChanged", { cancelable: true, }); if (onChange) { onChange(event, tmpValue as any); } if (!event.defaultPrevented) { console.log(tmpValue); AppConfig.setConfig({ [keyPath]: trim ? tmpValue.trim() as any : tmpValue as any, }); } }} defaultValue={value as string} value={(tmpValue || "") as string} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/ListBoxSettingItem/index.scss ================================================ .setting-view--list-box-setting-item-container { .question-mark-container { $size: 1.1rem; display: inline-block; margin-left: 0.5rem; width: $size; height: 100%; text-align: center; vertical-align: middle; & svg { width: $size; height: $size; cursor: pointer; } } & .options-container { margin-top: 0.6rem; display: flex; gap: 0.5rem 3rem; position: relative; user-select: none; $item-width: 220px; & .listbox-button { height: 2.6rem; width: $item-width; position: relative; display: flex; align-items: center; box-sizing: border-box; padding-left: 12px; padding-right: 24px; border-radius: 6px; background-color: var(--placeholderColor); border: 1px solid var(--dividerColor); & span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } &::after { position: absolute; right: 6px; content: ""; width: 0; height: 0; margin-left: 0.5rem; border: 4px solid transparent; border-left-color: currentColor; transform-origin: left center; transform: rotate(90deg); } } & .listbox-options { position: absolute; padding-inline-start: 0; margin-block-start: 0; border-radius: 4px; left: 0; top: 3rem; z-index: 10; width: $item-width; max-height: 280px; overflow-x: hidden; overflow-y: auto; list-style: none; & .listbox-option { list-style: none; height: 2.2rem; width: $item-width; line-height: 2.2rem; padding: 0 12px; vertical-align: middle; cursor: default; box-sizing: border-box; &[data-headlessui-state*="active"] { color: var(--primaryColor); } & div { overflow-x: hidden; white-space: nowrap; text-overflow: ellipsis; } } &:focus { outline: none; } } } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/ListBoxSettingItem/index.tsx ================================================ import { Listbox } from "@headlessui/react"; import "./index.scss"; import Condition, { IfTruthy } from "@/renderer/components/Condition"; import Loading from "@/renderer/components/Loading"; import { isBasicType } from "@/common/normalize-util"; import useVirtualList from "@/hooks/useVirtualList"; import { rem } from "@/common/constant"; import { ReactNode, useRef } from "react"; import SvgAsset from "@/renderer/components/SvgAsset"; import { Tooltip } from "react-tooltip"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; import AppConfig from "@shared/app-config/renderer"; interface ListBoxSettingItemProps { keyPath: T; label?: string; options: Array | null; onChange?: (event: Event, newConfig: IAppConfig[T]) => void; renderItem?: (item: IAppConfig[T]) => ReactNode; width?: number | string; toolTip?: string; } export default function ListBoxSettingItem( props: ListBoxSettingItemProps, ) { const { keyPath, label, options, onChange, renderItem, width, toolTip, } = props; const value = useAppConfig(keyPath); return (
    { const event = new Event("ConfigChanged", { cancelable: true, }); if (onChange) { onChange(event, newVal); } if (!event.defaultPrevented) { AppConfig.setConfig({ [keyPath]: newVal, }); } } } >
    {label}
    {renderItem ? renderItem(value) : isBasicType(value) ? (value as string) : ""}
    ); } interface IListBoxOptionsProps { options: Array | null; renderItem?: (item: IAppConfig[T]) => ReactNode; width?: number | string; } function ListBoxOptions( props: IListBoxOptionsProps, ) { const { options, renderItem, width } = props; const containerRef = useRef(); const virtualController = useVirtualList({ data: options ?? [], estimateItemHeight: 2.2 * rem, getScrollElement: () => containerRef.current, renderCount: 40, fallbackRenderCount: 20, }); return (
    }>
    {virtualController.virtualItems?.map?.((virtualItem) => (
    {renderItem ? renderItem(virtualItem.dataItem) : isBasicType(virtualItem.dataItem) ? (virtualItem.dataItem as string) : ""}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/MultiRadioGroupSettingItem/index.scss ================================================ .setting-view--radio-group-setting-item-container { & .options-container { margin-top: 0.6rem; display: flex; gap: 0.5rem 3rem; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/MultiRadioGroupSettingItem/index.tsx ================================================ import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; import classNames from "@/renderer/utils/classnames"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; import AppConfig from "@shared/app-config/renderer"; type ExtractArrayItem = T extends Array ? R : never; interface IRadioGroupSettingItemProps { keyPath: T; label?: string; options: IAppConfig[T]; renderItem?: (item: ExtractArrayItem) => string; direction?: "horizontal" | "vertical"; } /** * 多选 * @param props * @constructor */ export default function MultiRadioGroupSettingItem( props: IRadioGroupSettingItemProps, ) { const { keyPath, label, options, renderItem, direction = "horizontal", } = props; const value = useAppConfig(keyPath); return (
    {label}
    {(options as any[]).map((option, index) => { const checked = (value as Array)?.includes(option); const title = renderItem ? renderItem(option) : (option as string); return (
    { let newValue = []; if (checked) { newValue = (value as Array)?.filter( (it) => it !== option, ) ?? []; } else { newValue = [...(value as Array || []), option]; } AppConfig.setConfig({ [keyPath]: newValue, }); }} >
    {checked ? : null}
    {title}
    ); })}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/PathSettingItem/index.scss ================================================ .setting-view--path-setting-item-container { & .options-container { margin-top: 0.6rem; display: flex; align-items: center; gap: 0.5rem 1rem; position: relative; & .path-container { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 2rem; } } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/PathSettingItem/index.tsx ================================================ import AppConfig from "@shared/app-config/renderer"; import "./index.scss"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; import { dialogUtil, fsUtil, shellUtil } from "@shared/utils/renderer"; interface PathSettingItemProps { keyPath: T; label?: string; } export default function PathSettingItem( props: PathSettingItemProps, ) { const { keyPath, label } = props; const value = useAppConfig(keyPath); const { t } = useTranslation(); return (
    {label}
    {value as string}
    { const result = await dialogUtil.showOpenDialog({ title: t("settings.choose_path"), defaultPath: value as string, properties: ["openDirectory"], buttonLabel: t("common.confirm"), }); if (!result.canceled) { AppConfig.setConfig({ [keyPath]: result.filePaths[0]! as any, }); } }} > {t("settings.change_path")}
    { if (await fsUtil.isFolder(value as string)) { shellUtil.openPath(value as string); } else { toast.error(t("settings.folder_not_exist")); } }} > {t("settings.open_folder")}
    {/* */}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/RadioGroupSettingItem/index.scss ================================================ .setting-view--radio-group-setting-item-container { & .options-container { margin-top: 0.6rem; display: flex; gap: 0.5rem 3rem; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/components/RadioGroupSettingItem/index.tsx ================================================ import { RadioGroup } from "@headlessui/react"; import "./index.scss"; import SvgAsset from "@/renderer/components/SvgAsset"; import classNames from "@/renderer/utils/classnames"; import { IAppConfig } from "@/types/app-config"; import useAppConfig from "@/hooks/useAppConfig"; import AppConfig from "@shared/app-config/renderer"; interface IRadioGroupSettingItemProps { keyPath: T; label?: string; options: Array renderItem?: (item: IAppConfig[T]) => string; direction?: "horizontal" | "vertical"; } export default function RadioGroupSettingItem( props: IRadioGroupSettingItemProps, ) { const { keyPath, label, options, direction = "horizontal", renderItem, } = props; const value = useAppConfig(keyPath); return (
    { AppConfig.setConfig({ [keyPath]: val, }); }} > {label}
    {options.map((option, index) => ( {({ checked }) => { const title = renderItem ? renderItem(option) : option as string; return (
    {checked ? : null}
    {title}
    ); }}
    ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/index.scss ================================================ .setting-view--container { width: 100%; height: 100%; display: flex; flex-direction: column; & .setting-view--header { width: 100%; flex-shrink: 0; padding-left: 1.5rem; padding-right: 1.5rem; box-sizing: border-box; & .tab-list-container { overflow-x: auto; } } & .setting-view--body { flex: 1; overflow-y: auto; margin-top: 1rem; padding-bottom: 1rem; height: max-content; padding-left: 1.5rem; padding-right: 1.5rem; & .setting-view--body-item-container { width: 100%; & .setting-view--body-title { font-size: 1.1rem; font-weight: 600; margin-top: 1rem; margin-bottom: 1rem; } } } & .setting-row { width: 100%; position: relative; margin-top: 1.2rem; margin-bottom: 1.2rem; & .option-item-container { display: flex; align-items: center; cursor: pointer; font-size: 1.1rem; width: fit-content; & .checkbox { width: 1rem; height: 1rem; border-radius: 2px; border: 1px solid currentColor; margin-right: 0.4rem; position: relative; & svg { position: absolute; width: 1rem; height: 1rem; left: 0; top: 0; } } } } & .label-container { opacity: 0.6; font-size: 1rem; font-weight: 600; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/index.tsx ================================================ import "./index.scss"; import routers from "./routers"; import { useEffect, useRef, useState } from "react"; import Condition from "@/renderer/components/Condition"; import { useTranslation } from "react-i18next"; import camelToSnake from "@/common/camel-to-snake"; export default function SettingView() { const [selected, setSelected] = useState(routers[0].id); const { t } = useTranslation(); const intersectionObserverRef = useRef(); const bodyContainerRef = useRef(); const intersectionRatioRef = useRef>(new Map()); useEffect(() => { intersectionObserverRef.current = new IntersectionObserver( (targets) => { const ratio = intersectionRatioRef.current; targets.forEach((target) => { ratio.set(target.target.id, target.intersectionRatio); }); let maxVal = 0; let maxId; for (const entry of ratio.entries()) { if (entry[1] > maxVal) { maxId = entry[0]; maxVal = entry[1]; } } setSelected(maxId.slice(8)); }, { root: bodyContainerRef.current, threshold: [0, 0.2, 0.8, 1], }, ); for (const setting of routers) { const target = document.getElementById(`setting-${setting.id}`); if (target) { intersectionObserverRef.current.observe(target); } } return () => { document .getElementById("page-container") ?.classList?.remove("page-container-full-width"); intersectionObserverRef.current.disconnect(); intersectionObserverRef.current = null; intersectionRatioRef.current.clear(); intersectionRatioRef.current = null; }; }, []); return (
    {routers.map((setting) => (
    { document .getElementById(`setting-${setting.id}`) ?.scrollIntoView({ behavior: "smooth", }); }} > {t(`settings.section_name.${camelToSnake(setting.id)}`)}
    ))}
    {routers.map((setting, index) => { const Component = setting.component as any; return (
    {t(`settings.section_name.${camelToSnake(setting.id)}`)}
    ); })}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/About/index.scss ================================================ .setting-view--about-container{ & .about-version { display: flex; align-items: center; column-gap: 16px; } & .wx-channel { height: 150px; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/About/index.tsx ================================================ import A from "@/renderer/components/A"; import wxChannelImg from "@/assets/imgs/wechat_channel1.png"; import checkUpdate from "@/renderer/utils/check-update"; import { toast } from "react-toastify"; import "./index.scss"; import { Trans, useTranslation } from "react-i18next"; import { getGlobalContext } from "@/shared/global-context/renderer"; export default function About() { const { t } = useTranslation(); return ( ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Backup/index.scss ================================================ .setting-view--backup-container { width: 100%; & .backup-row { display: flex; gap: 12px; } & .webdav-backup-container { display: grid; grid-template-columns: 33% 33%; gap: 18px; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Backup/index.tsx ================================================ import "./index.scss"; import MusicSheet from "@/renderer/core/music-sheet"; import { toast } from "react-toastify"; import RadioGroupSettingItem from "../../components/RadioGroupSettingItem"; import InputSettingItem from "../../components/InputSettingItem"; import { AuthType, createClient } from "webdav"; import BackupResume from "@/renderer/core/backup-resume"; import { useTranslation } from "react-i18next"; import AppConfig from "@shared/app-config/renderer"; import { dialogUtil, fsUtil } from "@shared/utils/renderer"; export default function Backup() { const { t } = useTranslation(); async function onBackupClick() { const url = AppConfig.getConfig("backup.webdav.url"); const username = AppConfig.getConfig("backup.webdav.username"); const password = AppConfig.getConfig("backup.webdav.password"); try { if ( url && username && password ) { const client = createClient(url, { authType: AuthType.Password, username: username, password: password, }); const sheetDetails = await MusicSheet.frontend.exportAllSheetDetails(); const backUp = JSON.stringify( { musicSheets: sheetDetails, }, undefined, 0, ); if (!(await client.exists("/MusicFree"))) { await client.createDirectory("/MusicFree"); } // 临时文件 await client.putFileContents( "/MusicFree/MusicFreeBackup.json", backUp, { overwrite: true, }, ); toast.success(t("settings.backup.backup_success")); } else { toast.error(t("settings.backup.webdav_data_not_complete")); } } catch (e) { toast.error( t("settings.backup.backup_fail", { reason: e?.message, }), ); } } async function onResumeClick() { const url = AppConfig.getConfig("backup.webdav.url"); const username = AppConfig.getConfig("backup.webdav.username"); const password = AppConfig.getConfig("backup.webdav.password"); try { if ( url && username && password ) { const client = createClient(url, { authType: AuthType.Password, username: username, password: password, }); if (!(await client.exists("/MusicFree/MusicFreeBackup.json"))) { throw new Error( t("settings.backup.webdav_backup_file_not_exist"), ); } const resumeData = await client.getFileContents( "/MusicFree/MusicFreeBackup.json", { format: "text", }, ); await BackupResume.resume( resumeData, AppConfig.getConfig("backup.resumeBehavior") === "overwrite", ); toast.success(t("settings.backup.resume_success")); } else { toast.error(t("settings.backup.webdav_data_not_complete")); } } catch (e) { toast.error( t("settings.backup.resume_fail", { reason: e?.message, }), ); } } return (
    t("settings.backup.resume_mode_" + item)} >
    {t("settings.backup.backup_by_file")}
    { const result = await dialogUtil.showSaveDialog({ properties: ["showOverwriteConfirmation", "createDirectory"], filters: [ { name: t("settings.backup.musicfree_backup_file"), extensions: ["json", "txt"], }, ], title: t("settings.backup.backup_to"), }); if (!result.canceled && result.filePath) { const sheetDetails = await MusicSheet.frontend.exportAllSheetDetails(); const backUp = JSON.stringify({ musicSheets: sheetDetails, }); await fsUtil.writeFile(result.filePath, backUp, "utf-8"); toast.success(t("settings.backup.backup_success")); } }} > {t("settings.backup.backup_music_sheet")}
    { const result = await dialogUtil.showOpenDialog({ properties: ["openFile"], filters: [ { name: t("settings.backup.musicfree_backup_file"), extensions: ["json", "txt"], }, ], title: t("common.open"), }); if (!result.canceled && result.filePaths) { try { const rawSheets = (await fsUtil.readFile( result.filePaths[0], "utf-8", )) as string; await BackupResume.resume( rawSheets, AppConfig.getConfig("backup.resumeBehavior") === "overwrite", ); toast.success(t("backup.backup_success")); } catch (e) { toast.error( t("backup.backup_fail", { reason: e?.message, }), ); } } }} > {t("settings.backup.resume_music_sheet")}
    {t("settings.backup.backup_by_webdav")}
    {t("settings.backup.backup_music_sheet")}
    {t("settings.backup.resume_music_sheet")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Download/index.scss ================================================ .setting-view--download-container { width: 100%; } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Download/index.tsx ================================================ import "./index.scss"; import RadioGroupSettingItem from "../../components/RadioGroupSettingItem"; import ListBoxSettingItem from "../../components/ListBoxSettingItem"; import Downloader from "@/renderer/core/downloader"; import PathSettingItem from "../../components/PathSettingItem"; import { useTranslation } from "react-i18next"; const concurrencyList = Array(20) .fill(0) .map((_, index) => index + 1); export default function Download() { const { t } = useTranslation(); return (
    { Downloader.setDownloadingConcurrency(newConfig); }} label={t("settings.download.max_concurrency")} > t("media.music_quality_" + item)} > t("settings.download.download_" + item + "_quality_version")} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Lyric/index.scss ================================================ ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Lyric/index.tsx ================================================ import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import "./index.scss"; import ColorPickerSettingItem from "../../components/ColorPickerSettingItem"; import ListBoxSettingItem from "../../components/ListBoxSettingItem"; import FontPickerSettingItem from "../../components/FontPickerSettingItem"; import { IfTruthy } from "@/renderer/components/Condition"; import { useTranslation } from "react-i18next"; import { getGlobalContext } from "@/shared/global-context/renderer"; import { appWindowUtil } from "@shared/utils/renderer"; const numberArray = Array(65) .fill(0) .map((_, index) => 16 + index); export default function Lyric() { const { t } = useTranslation(); return (
    { appWindowUtil.setLyricWindow(checked); }} > {/* */}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Network/index.scss ================================================ .setting-view--network-container { width: 100%; & .proxy-container { display: grid; grid-template-columns: 33% 33%; gap: 18px; & .proxy-item { display: flex; & input { flex: 1; } } } & .network-cache-container { display: flex; align-items: center; gap: 24px; } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Network/index.tsx ================================================ import "./index.scss"; import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import InputSettingItem from "../../components/InputSettingItem"; import { useEffect, useState } from "react"; import { normalizeFileSize } from "@/common/normalize-util"; import { Trans, useTranslation } from "react-i18next"; import useAppConfig from "@/hooks/useAppConfig"; import { appUtil } from "@shared/utils/renderer"; export default function Network() { const proxyEnabled = !!useAppConfig("network.proxy.enabled"); const [cacheSize, setCacheSize] = useState(NaN); const { t } = useTranslation(); useEffect(() => { appUtil.getCacheSize().then((res) => { setCacheSize(res); }); }, []); return (
    { setCacheSize(0); appUtil.clearCache(); }} > {t("settings.network.clear_cache")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Normal/index.scss ================================================ .setting-view--normal-container { width: 100%; } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Normal/index.tsx ================================================ import RadioGroupSettingItem from "../../components/RadioGroupSettingItem"; import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import MultiRadioGroupSettingItem from "../../components/MultiRadioGroupSettingItem"; import ListBoxSettingItem from "../../components/ListBoxSettingItem"; import "./index.scss"; import { changeLang, getLangList } from "@/shared/i18n/renderer"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; import { getGlobalContext } from "@/shared/global-context/renderer"; export default function Normal() { const { t } = useTranslation(); const allLangs = getLangList(); return (
    t("settings.normal." + item)} > {getGlobalContext().platform === "win32" ? ( { if (item === "artwork") { return t("settings.normal.current_artwork"); } else { return t("settings.normal.main_window"); } }} > ) : null} { return t("media.media_" + item); }} > { evt.preventDefault(); const success = await changeLang(lang); if (!success) { toast.warning(t("settings.normal.toast_switch_language_fail")); } }} options={allLangs} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/PlayMusic/index.scss ================================================ .setting-view--play-music-container { width: 100%; } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/PlayMusic/index.tsx ================================================ import "./index.scss"; import RadioGroupSettingItem from "../../components/RadioGroupSettingItem"; import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import { useOutputAudioDevices } from "@/hooks/useMediaDevices"; import ListBoxSettingItem from "../../components/ListBoxSettingItem"; import trackPlayer from "@renderer/core/track-player"; import { useTranslation } from "react-i18next"; import AppConfig from "@shared/app-config/renderer"; export default function PlayMusic() { const audioDevices = useOutputAudioDevices(); const { t } = useTranslation(); return (
    t("media.music_quality_" + it)} > t("settings.play_music.play_" + it + "_quality_version")} > { if (it === "pause") { return t("settings.play_music.pause"); } else { return t("settings.play_music.skip_to_next"); } }} > { if (it === "normal") { return t("settings.play_music.add_music_to_playlist"); } else { return t("settings.play_music.replace_playlist_with_musiclist"); } }} > { return item ? item.label : t("common.default"); }} width={"320px"} onChange={async (evt, item) => { evt.preventDefault(); await trackPlayer.setAudioOutputDevice(item.deviceId); AppConfig.setConfig({ "playMusic.audioOutputDevice": item.toJSON(), }); }} options={audioDevices} > { if (it === "pause") { return t("settings.play_music.pause"); } else { return t("settings.play_music.continue_playing"); } }} options={["pause", "play"]} >
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Plugin/index.scss ================================================ .setting-view--plugin-container { width: 100%; } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/Plugin/index.tsx ================================================ import "./index.scss"; import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import { useTranslation } from "react-i18next"; export default function Plugin() { const { t } = useTranslation(); return (
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/ShortCut/index.scss ================================================ .setting-view--short-cut-container { width: 100%; } .setting-view--short-cut-table-row { width: 45rem; display: flex; align-items: center; height: 3rem; line-height: 3rem; justify-content: space-between; & .short-cut-cell { width: 14rem; flex-shrink: 0; flex-grow: 0; white-space: nowrap; user-select: none; ime-mode: disabled; &:first-child { width: 9rem; } & .short-cut-item--container { position: relative; display: flex; align-items: center; & [data-disabled=true] { opacity: 0.6; pointer-events: none; } &:hover { & .short-cut-item--clear-button { opacity: 1; } } & .short-cut-item--clear-button { position: absolute; right: 4px; width: 1.2rem; height: 1.2rem; line-height: 0; transition: opacity ease-in-out 0.2s; opacity: 0; & svg { height: 1.2rem; width: 1.2rem; } } } } } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/ShortCut/index.tsx ================================================ import "./index.scss"; import CheckBoxSettingItem from "../../components/CheckBoxSettingItem"; import { useEffect, useRef, useState } from "react"; import hotkeys from "hotkeys-js"; import { useTranslation } from "react-i18next"; import useAppConfig from "@/hooks/useAppConfig"; import { IAppConfig } from "@/types/app-config"; import shortCut from "@shared/short-cut/renderer"; import { shortCutKeys } from "@/common/constant"; import SvgAsset from "@renderer/components/SvgAsset"; export default function ShortCut() { const { t } = useTranslation(); return (
    ); } type IShortCutKeys = keyof IAppConfig["shortCut.shortcuts"]; function ShortCutTable() { const { t } = useTranslation(); const enableLocalShortCut = useAppConfig("shortCut.enableLocal"); const enableGlobalShortCut = useAppConfig("shortCut.enableGlobal"); const shortCuts = useAppConfig("shortCut.shortcuts"); return (
    {t("settings.short_cut.ability")}
    {t("settings.short_cut.enable_local")}
    {t("settings.short_cut.enable_global")}
    {shortCutKeys.map((it: string) => (
    {t(`settings.short_cut.${it}`)}
    { shortCut.registerLocalShortCut(it as IShortCutKeys, val); }} showClearButton onClear={() => { shortCut.unregisterLocalShortCut(it as IShortCutKeys); }} >
    { shortCut.registerGlobalShortCut(it as IShortCutKeys, val); }} showClearButton onClear={() => { shortCut.unregisterGlobalShortCut(it as IShortCutKeys); }} >
    ))}
    ); } interface IShortCutItemProps { enabled?: boolean; isGlobal?: boolean; value?: string[]; onChange?: (sc?: string[]) => void; showClearButton?: boolean; onClear?: () => void; } function formatValue(val: string[]) { return val.join(" + "); } function keyCodeMap(code: string) { switch (code) { case "arrowup": return "Up"; case "arrowdown": return "Down"; case "arrowleft": return "Left"; case "arrowright": return "Right"; default: return code; } } function ShortCutItem(props: IShortCutItemProps) { const { value, onChange, enabled, isGlobal, showClearButton, onClear } = props; const [tmpValue, setTmpValue] = useState(); const realValue = formatValue(tmpValue ?? value ?? []); const isRecordingRef = useRef(false); const scopeRef = useRef(Math.random().toString().slice(2)); const recordedKeysRef = useRef(new Set()); const { t } = useTranslation(); useEffect(() => { hotkeys( "*", { scope: scopeRef.current, keyup: true, }, (evt) => { const type = evt.type; let key = evt.key.toLowerCase(); if (evt.code === "Space") { key = "Space"; } if (type === "keydown") { isRecordingRef.current = true; if (key === "meta") { setTmpValue(null); isRecordingRef.current = false; recordedKeysRef.current.clear(); } else { if (!recordedKeysRef.current.has(key)) { recordedKeysRef.current.add(key); setTmpValue( [...recordedKeysRef.current].map((it) => it.replace(/^(.)/, (_, $1: string) => $1.toUpperCase()), ), ); } } } else if (type === "keyup" && isRecordingRef.current) { isRecordingRef.current = false; // 开始结算 const recordedSet = recordedKeysRef.current; const _recordShortCutKey = []; let statusCode = 0; if (recordedSet.has("ctrl") || recordedSet.has("control")) { _recordShortCutKey.push("Ctrl"); recordedSet.delete("ctrl"); recordedSet.delete("control"); statusCode |= 1; } if (recordedSet.has("command")) { _recordShortCutKey.push("Command"); recordedSet.delete("command"); statusCode |= 1; } if (recordedSet.has("option")) { _recordShortCutKey.push("Option"); recordedSet.delete("option"); statusCode |= 1; } if (recordedSet.has("shift")) { _recordShortCutKey.push("Shift"); recordedSet.delete("shift"); statusCode |= 1; } if (recordedSet.has("alt")) { _recordShortCutKey.push("Alt"); recordedSet.delete("alt"); statusCode |= 1; } if (recordedSet.size === 1 && (isGlobal ? statusCode : true)) { _recordShortCutKey.push( keyCodeMap([...recordedSet.values()][0]).replace( /^(.)/, (_, $1: string) => $1.toUpperCase(), ), ); setTmpValue(_recordShortCutKey); onChange?.(_recordShortCutKey); } else { setTmpValue(null); } recordedKeysRef.current.clear(); } }, ); }, []); return (
    { e.preventDefault(); }} onFocus={() => { hotkeys.setScope(scopeRef.current); }} onBlur={() => { hotkeys.setScope("all"); setTmpValue(null); recordedKeysRef.current.clear(); }} > { (enabled && showClearButton) ?
    : null }
    ); } ================================================ FILE: src/renderer/pages/main-page/views/setting-view/routers/index.ts ================================================ /** 配置 */ import About from "./About"; import Backup from "./Backup"; import Download from "./Download"; import Lyric from "./Lyric"; import Network from "./Network"; import Normal from "./Normal"; import PlayMusic from "./PlayMusic"; import Plugin from "./Plugin"; import ShortCut from "./ShortCut"; export default [ { id: "normal", component: Normal, }, { id: "playMusic", component: PlayMusic, }, { id: "download", component: Download, }, { id: "lyric", component: Lyric, }, { id: "plugin", component: Plugin, }, { id: "shortCut", component: ShortCut, }, { id: "network", component: Network, }, { id: "backup", component: Backup, }, { id: "about", component: About, }, ]; ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/LocalThemes/index.scss ================================================ .local-themes-container { width: 100%; margin-top: 14px; & .local-themes-inner-container { display: grid; gap: 24px 36px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } } .theme-install-local { display: flex; align-items: center; justify-content: center; border: 1px dashed var(--dividerColor); cursor: pointer; & svg { height: 60%; opacity: 0.6; } } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/LocalThemes/index.tsx ================================================ import { toast } from "react-toastify"; import SvgAsset from "@/renderer/components/SvgAsset"; import { useTranslation } from "react-i18next"; import ThemePack from "@/shared/themepack/renderer"; import ThemeItem from "../ThemeItem"; import "./index.scss"; import { dialogUtil } from "@shared/utils/renderer"; export default function LocalThemes() { const currentThemePack = ThemePack.useCurrentThemePack(); const localThemePacks = ThemePack.useLocalThemePacks(); const { t } = useTranslation(); return (
    { try { const result = await dialogUtil.showOpenDialog({ title: t("theme.install_theme"), buttonLabel: t("common.install"), filters: [ { name: t("theme.musicfree_theme"), extensions: ["mftheme", "zip"], }, { name: t("theme.all_files"), extensions: ["*"], }, ], properties: ["openFile", "multiSelections"], }); if (!result.canceled) { const themePackPaths = result.filePaths; for (const themePackPath of themePackPaths) { const themePackConfig = await ThemePack.installThemePack( themePackPath, ); toast.success( t("theme.install_theme_success", { name: themePackConfig?.name ? `「${themePackConfig.name}」` : "", }), ); } } } catch (e) { toast.warn( t("theme.install_theme_fail", { name: e?.message ? `「${e.message}」` : "", }), ); } }} >
    {localThemePacks.map((it) => ( ))}
    ); } // function ThemeItem(props: IThemeItemProps) { // const { selected, themePack } = props; // const { t } = useTranslation(); // return ( //
    { // Themepack.selectTheme(themePack); // }} // onContextMenu={(e) => { // if (!themePack) { // return; // } // showThemeContextMenu(themePack, e.clientX, e.clientY); // }} // title={themePack?.description} // > //
    //
    // {themePack ? themePack.name : t("common.default")} //
    //
    // ); // } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/hooks/useRemoteThemes.ts ================================================ import { RequestStateCode, themePackStoreBaseUrl } from "@/common/constant"; import useMounted from "@/hooks/useMounted"; import Themepack from "@/shared/themepack/renderer"; import axios from "axios"; import { useEffect, useState } from "react"; let themeStoreConfig: IThemeStoreItem[]; interface IThemeStoreItem { publishName: string; hash: string; packageName: string; config: ICommon.IThemePack; id?: string; } function raceWithData(promises: Array>): Promise { const promiseCount = promises.length; return new Promise((resolve, reject) => { let isResolved = false; let rejectedNum = 0; promises.forEach((promise) => { promise .then((data) => { if (!isResolved) { isResolved = true; resolve(data); } }) .catch((e) => { ++rejectedNum; if (rejectedNum === promiseCount) { reject(e); } }); }); }); } export default function () { const [themes, setThemes] = useState(themeStoreConfig || []); const [loadingState, setLoadingState] = useState( RequestStateCode.PENDING_FIRST_PAGE, ); const isMounted = useMounted(); useEffect(() => { if (themeStoreConfig) { setThemes(themeStoreConfig); setLoadingState(RequestStateCode.FINISHED); } else { raceWithData( themePackStoreBaseUrl.map( async (it, index) => [await axios.get(it + ".publish/publish.json").then(res => { if (typeof res.data !== "object") { throw new Error("Invalid data"); } return res; }), index] as const, ), ) .then(([res, index]) => { const data: IThemeStoreItem[] = res.data; const pickedUrl = themePackStoreBaseUrl[index]; data.forEach((theme) => { theme.config.srcUrl = `${pickedUrl}.publish/${theme.publishName}.mftheme`; if (theme.config.preview) { theme.config.preview = Themepack.replaceAlias( theme.config.preview, pickedUrl + theme.packageName + "/", false, ); } if (theme.config.thumb) { theme.config.thumb = Themepack.replaceAlias( theme.config.thumb, pickedUrl + theme.packageName + "/", false, ); } }); themeStoreConfig = data; if (isMounted.current) { setLoadingState(RequestStateCode.FINISHED); setThemes(data); } }) .catch((e) => { setLoadingState(RequestStateCode.ERROR); }); } }, []); return [themes, loadingState] as const; } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/index.scss ================================================ .remote-themes-container { width: 100%; margin-top: 14px; & .remote-themes-inner-container { display: grid; gap: 24px 36px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } & .remote-themes-description { margin: 1em 0; } & .remote-themes-load-error { width: 100%; height: 200px; display: flex; align-items: center; justify-content: center; } } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/index.tsx ================================================ import Loading from "@/renderer/components/Loading"; import "./index.scss"; import useRemoteThemes from "./hooks/useRemoteThemes"; import SwitchCase from "@/renderer/components/SwitchCase"; import { RequestStateCode } from "@/common/constant"; import ThemeItem from "../ThemeItem"; import ThemePack from "@/shared/themepack/renderer"; import { Trans, useTranslation } from "react-i18next"; import A from "@/renderer/components/A"; export default function RemoteThemes() { const [themes, loadingState] = useRemoteThemes(); const currentTheme = ThemePack.useCurrentThemePack(); const localThemes = ThemePack.useLocalThemePacks(); const { t } = useTranslation(); return (
    ), }} >
    {themes.map((it) => ( it.hash === localTheme.hash) } installed={ it.id && localThemes.some((localTheme) => it.id === localTheme.id) } > ))}
    {t("theme.load_remote_theme_error")}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/ThemeItem/index.scss ================================================ .theme-item-container { width: 100%; position: relative; display: flex; flex-direction: column; gap: 0.5em; & .theme-thumb-container { height: 100px; position: relative; overflow: hidden; border-radius: 6px; & .theme-selected { width: 100%; height: 100%; box-sizing: border-box; position: absolute; top: 0; left: 0; bottom: 0; right: 0; border-radius: 6px; border: 4px solid var(--primaryColor); } & .theme-thumb { position: absolute; top: 0; left: 0; bottom: 0; right: 0; width: 100%; height: 100%; object-fit: cover; } & .theme-options-mask { position: absolute; top: 0; left: 0; bottom: 0; right: 0; width: 100%; height: 100%; transition: all 300ms ease-in-out; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 8px; opacity: 0; &[data-show="true"] { opacity: 1; background-color: rgba($color: #000, $alpha: 0.8); } & .theme-downloading { transform: scale(0.7); } & .theme-option-button { border-radius: 4px; border: 1px solid currentColor; font-size: 0.9rem; padding: 4px 10px; width: fit-content; opacity: 0.8; user-select: none; color: white; &:hover { opacity: 1; } } } } & .theme-name { font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; cursor: pointer; &:hover { color: var(--primaryColor); } } & .theme-author { font-size: 0.9rem; opacity: 0.7; user-select: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/components/ThemeItem/index.tsx ================================================ import { useTranslation } from "react-i18next"; import "./index.scss"; import { If, IfTruthy } from "@/renderer/components/Condition"; import { useState } from "react"; import Themepack from "@/shared/themepack/renderer"; import { toast } from "react-toastify"; import Loading from "@/renderer/components/Loading"; interface IProps { config: ICommon.IThemePack; hash?: string; type: "remote" | "local"; selected?: boolean; /**[Remote Only] 主题的最新版是否已经在本地安装 */ latestInstalled?: boolean; /**[Remote Only] 主题是否已经在本地安装 */ installed?: boolean; } export default function ThemeItem(props: IProps) { const { config, type, selected, latestInstalled, installed, hash } = props; const [isHover, setIsHover] = useState(false); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const selectTheme = async () => { try { if (type === "local") { await Themepack.selectTheme(config); } else { if (latestInstalled) { await Themepack.selectThemeByHash(hash); } else { setIsLoading(true); const themePack = await Themepack.installRemoteThemePack( config.srcUrl, config.id, ); await Themepack.selectTheme(themePack); } } } catch (e) { toast.error( t("theme.invalid_theme", { reason: e?.message ?? "", }), ); } setIsLoading(false); }; return (
    { setIsHover(true); }} onMouseLeave={() => { setIsHover(false); }} >
    {config.preview?.startsWith("#") ? (
    ) : ( )}
    {isLoading ? (
    ) : (
    {latestInstalled ? t("theme.use_theme") : installed ? t("theme.update_theme") : t("theme.download_and_use")}
    {t("theme.use_theme")}
    {hash && (
    { Themepack.uninstallThemePack(config); }} > {t("common.uninstall")}
    )}
    )}
    {config.name}
    {t("media.media_type_artist")}: {config.author}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/theme-view/index.scss ================================================ ================================================ FILE: src/renderer/pages/main-page/views/theme-view/index.tsx ================================================ import { Tab } from "@headlessui/react"; import React from "react"; import { useTranslation } from "react-i18next"; import RemoteThemes from "./components/RemoteThemes"; import LocalThemes from "./components/LocalThemes"; const routes = ["local", "remote"]; export default function ThemeView() { const { t } = useTranslation(); return (
    {routes.map((it) => ( {t(`theme.tab_${it}`)} ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/toplist-detail-view/hooks/useTopListDetail.ts ================================================ import { RequestStateCode } from "@/common/constant"; import { useEffect, useRef, useState } from "react"; import PluginManager from "@shared/plugin-manager/renderer"; export default function useTopListDetail( topListItem: IMusic.IMusicSheetItem | null, platform: string, ) { const [mergedTopListItem, setMergedTopListItem] = useState | null>(topListItem); const pageRef = useRef(1); const [requestState, setRequestState] = useState(RequestStateCode.IDLE); async function loadMore(){ if (!topListItem) { return; } try { if (pageRef.current === 1) { setRequestState(RequestStateCode.PENDING_FIRST_PAGE); } else { setRequestState(RequestStateCode.PENDING_REST_PAGE); } const result = await PluginManager.callPluginDelegateMethod({ platform }, "getTopListDetail", topListItem, pageRef.current); if (!result) { throw new Error(); } const currentPage = pageRef.current; setMergedTopListItem((prev) => ({ ...prev, ...(result.topListItem), musicList: currentPage === 1 ? (result.musicList ?? []): [...prev.musicList, ...result.musicList], })); if (!result.isEnd) { setRequestState(RequestStateCode.PARTLY_DONE); } else { setRequestState(RequestStateCode.FINISHED); } pageRef.current++; } catch { setRequestState(RequestStateCode.FINISHED); } } useEffect(() => { if (topListItem === null) { return; } loadMore(); }, []); return [mergedTopListItem, requestState, loadMore] as const; } ================================================ FILE: src/renderer/pages/main-page/views/toplist-detail-view/index.tsx ================================================ import useTopListDetail from "./hooks/useTopListDetail"; import { useParams } from "react-router-dom"; import MusicSheetlikeView from "@/renderer/components/MusicSheetlikeView"; export default function TopListDetailView() { const params = useParams(); const [topListDetail, state, loadMore] = useTopListDetail( history.state?.usr?.toplist, params?.platform, ); return (
    ); } ================================================ FILE: src/renderer/pages/main-page/views/toplist-view/hooks/useGetTopList.ts ================================================ import { produce } from "immer"; import { useCallback } from "react"; import { pluginsTopListStore } from "../store"; import { RequestStateCode } from "@/common/constant"; import { useStore } from "@/common/store"; import PluginManager from "@shared/plugin-manager/renderer"; export default function useGetTopList() { const [pluginsTopList, setPluginsTopList] = useStore(pluginsTopListStore); const getTopList = useCallback( async (pluginHash: string) => { try { // 有数据/加载中直接返回 if ( pluginsTopList[pluginHash]?.data?.length || pluginsTopList[pluginHash]?.state & RequestStateCode.PENDING_FIRST_PAGE ) { return; } setPluginsTopList( produce((draft) => { draft[pluginHash] = { state: RequestStateCode.PENDING_FIRST_PAGE, data: [], }; }), ); const result = await PluginManager.callPluginDelegateMethod( { hash: pluginHash }, "getTopLists", ); setPluginsTopList( produce((draft) => { draft[pluginHash] = { data: result, state: RequestStateCode.FINISHED, }; }), ); } catch { setPluginsTopList( produce((draft) => { draft[pluginHash].state = RequestStateCode.FINISHED; }), ); } }, [pluginsTopList], ); return getTopList; } ================================================ FILE: src/renderer/pages/main-page/views/toplist-view/index.scss ================================================ .toplist-view--container { height: 100%; & .toplist-group-item--container { & .header { font-weight: 600; font-size: 1.5rem; margin-top: 1.5rem; letter-spacing: 0.05rem; user-select: none; } & .body { margin-top: 1.5rem; display: grid; width: 100%; grid-template-columns: repeat(5, 1fr); } } } ================================================ FILE: src/renderer/pages/main-page/views/toplist-view/index.tsx ================================================ import Condition from "@/renderer/components/Condition"; import MusicSheetlikeItem from "@/renderer/components/MusicSheetlikeItem"; import { Tab } from "@headlessui/react"; import { pluginsTopListStore } from "./store"; import { RequestStateCode } from "@/common/constant"; import Loading from "@/renderer/components/Loading"; import { useNavigate } from "react-router-dom"; import { useEffect } from "react"; import useGetTopList from "./hooks/useGetTopList"; import NoPlugin from "@/renderer/components/NoPlugin"; import Empty from "@/renderer/components/Empty"; import { useTranslation } from "react-i18next"; import "./index.scss"; import PluginManager from "@shared/plugin-manager/renderer"; export default function ToplistView() { const availablePlugins = PluginManager.getSortedSupportedPlugin("getTopLists"); const navigate = useNavigate(); const { t } = useTranslation(); return (
    } > { const usr = history.state.usr ?? {}; navigate("", { replace: true, state: { ...usr, pluginIndex: index, }, }); }} > {availablePlugins.map((plugin) => ( {plugin.platform} ))} {availablePlugins.map((plugin) => ( ))}
    ); } interface IToplistBodyProps { plugin: IPlugin.IPluginDelegate; } function ToplistBody(props: IToplistBodyProps) { const topLists = pluginsTopListStore.useValue(); const { plugin } = props; const getTopList = useGetTopList(); useEffect(() => { getTopList(plugin.hash); }, []); return ( } > } > {topLists[plugin.hash]?.data?.map((item, index) => ( ))} ); } interface IToplistGroupItemProps { groupItem: IMusic.IMusicSheetGroupItem; platform: string; } function ToplistGroupItem(props: IToplistGroupItemProps) { const { groupItem, platform } = props; const navigate = useNavigate(); return (
    {groupItem.title}
    {(groupItem.data ?? []).map((item) => ( { navigate(`/main/toplist-detail/${platform}`, { state: { toplist: { ...mediaItem, platform, }, }, }); }} > ))}
    ); } ================================================ FILE: src/renderer/pages/main-page/views/toplist-view/store/index.ts ================================================ import { RequestStateCode } from "@/common/constant"; import Store from "@/common/store"; export interface IPluginTopListResult { state: RequestStateCode; data: IMusic.IMusicSheetGroupItem[]; } export const pluginsTopListStore = new Store>({}); ================================================ FILE: src/renderer/utils/check-update.ts ================================================ import { compare } from "compare-versions"; import { showModal } from "../components/Modal"; import { getUserPreference } from "./user-perference"; import { appUtil } from "@shared/utils/renderer"; export default async function checkUpdate(forceCheck?: boolean) { /** checkupdate */ const updateInfo = await appUtil.checkUpdate(); if (updateInfo.update) { const skipVersion = getUserPreference("skipVersion"); if ( !forceCheck && skipVersion && compare(updateInfo.version, skipVersion, "<=") ) { return false; } showModal("Update", { currentVersion: updateInfo.version, update: updateInfo.update, }); return true; } return false; } ================================================ FILE: src/renderer/utils/classnames.ts ================================================ export default function classNames(cls: Record | Array) { if(Array.isArray(cls)){ return cls.join(" "); } return Object.getOwnPropertyNames(cls).filter(cl => cls[cl]).join(" "); } ================================================ FILE: src/renderer/utils/create-tmp-file.ts ================================================ import { getGlobalContext } from "@/shared/global-context/renderer"; import { nanoid } from "nanoid"; import { fsUtil } from "@shared/utils/renderer"; export async function createTmpFile(data: string) { const { appPath } = getGlobalContext(); if (!appPath.temp) { throw new Error("TempFile Path NotFound"); } const randomFileName = nanoid(); const filePath = window.path.resolve(appPath.temp, randomFileName); await fsUtil.writeFile(filePath, data, "utf-8"); return { fileName: randomFileName, filePath, async clearTmpFile() { await fsUtil.rimraf(filePath); }, }; } ================================================ FILE: src/renderer/utils/get-text-width.ts ================================================ let canvas: HTMLCanvasElement; interface IConfig { fontSize?: string | number; fontFamily?: string; } export default function(text: string, config: IConfig){ let { fontSize = "1rem", fontFamily = "sans-serif" } = config; if(typeof fontSize === "number") { fontSize = `${fontSize}px`; } if(!canvas) { canvas = document.createElement("canvas"); } const ctx = canvas.getContext("2d"); ctx.font = `${fontSize} ${fontFamily ?? ""}`; const metrics = ctx.measureText(text); return metrics.width; } ================================================ FILE: src/renderer/utils/get-url-ext.ts ================================================ export default function getUrlExt(url?: string) { if (!url) { return; } const urlObj = new URL(url); const ext = window.path.extname(urlObj.pathname); return ext; } ================================================ FILE: src/renderer/utils/groupBy.ts ================================================ type IndexableType = number | string | symbol; export default function groupBy, K extends keyof T> (values: T[], keyFinder: K | ((item: T) => K) ) { // if(Object.groupBy) { // return Object.groupBy(values, keyFinder); // } // using reduce to aggregate values return values.reduce((a, b) => { // depending upon the type of keyFinder // if it is function, pass the value to it // if it is a property, access the property const key: K = typeof keyFinder === "function" ? keyFinder(b) : b[keyFinder]; // aggregate values based on the keys if(!a[key]){ a[key] = [b]; }else{ a[key] = [...a[key], b]; } return a; }, {} as Record); } ================================================ FILE: src/renderer/utils/img-on-error.ts ================================================ import albumImg from "@/assets/imgs/album-cover.jpg"; import { SyntheticEvent } from "react"; export function setFallbackAlbum(evt: SyntheticEvent) { (evt.target as HTMLImageElement).src = albumImg; } ================================================ FILE: src/renderer/utils/is-local-music.ts ================================================ import { localPluginName } from "@/common/constant"; export default function isLocalMusic(mediaItem: IMedia.IMediaBase) { return mediaItem?.platform === localPluginName; } ================================================ FILE: src/renderer/utils/lyric-parser.ts ================================================ const timeReg = /\[[\d:.]+\]/g; const metaReg = /\[(.+):(.+)\]/g; type LyricMeta = Record; interface IOptions { musicItem?: IMusic.IMusicItem; translation?: string; } export interface IParsedLrcItem { /** 时间 s */ time: number; /** 歌词 */ lrc: string; /** 翻译 */ translation?: string; /** 位置 */ index: number; } export default class LyricParser { private _musicItem?: IMusic.IMusicItem; private meta: LyricMeta; private lrcItems: Array; private lastSearchIndex = 0; public hasTranslation = false; get musicItem() { return this._musicItem; } constructor(raw: string, options?: IOptions) { // init this._musicItem = options?.musicItem; let translation = options?.translation; if (!raw && translation) { raw = translation; translation = undefined; } const { lrcItems, meta } = this.parseLyricImpl(raw); this.meta = meta; this.lrcItems = lrcItems; if (translation) { this.hasTranslation = true; const transLrcItems = this.parseLyricImpl(translation).lrcItems; // 2 pointer let p1 = 0; let p2 = 0; while (p1 < this.lrcItems.length) { const lrcItem = this.lrcItems[p1]; while ( transLrcItems[p2].time < lrcItem.time && p2 < transLrcItems.length - 1 ) { ++p2; } if (transLrcItems[p2].time === lrcItem.time) { lrcItem.translation = transLrcItems[p2].lrc; } else { lrcItem.translation = ""; } ++p1; } } } getPosition(position: number): IParsedLrcItem | null { position = position - (this.meta?.offset ?? 0); let index; /** 最前面 */ if (!this.lrcItems[0] || position < this.lrcItems[0].time) { this.lastSearchIndex = 0; return null; } for ( index = this.lastSearchIndex; index < this.lrcItems.length - 1; ++index ) { if ( position >= this.lrcItems[index].time && position < this.lrcItems[index + 1].time ) { this.lastSearchIndex = index; return this.lrcItems[index]; } } for (index = 0; index < this.lastSearchIndex; ++index) { if ( position >= this.lrcItems[index].time && position < this.lrcItems[index + 1].time ) { this.lastSearchIndex = index; return this.lrcItems[index]; } } index = this.lrcItems.length - 1; this.lastSearchIndex = index; return this.lrcItems[index]; } getLyricItems() { return this.lrcItems; } getMeta() { return this.meta; } toString(options?: { withTimestamp?: boolean; type?: "raw" | "translation"; }) { const { type = "raw", withTimestamp = true } = options || {}; if (withTimestamp) { return this.lrcItems .map( (item) => `${this.timeToLrctime(item.time)} ${ type === "raw" ? item.lrc : item.translation }`, ) .join("\r\n"); } else { return this.lrcItems .map((item) => (type === "raw" ? item.lrc : item.translation)) .join("\r\n"); } } /** [xx:xx.xx] => x s */ private parseTime(timeStr: string): number { let result = 0; const nums = timeStr.slice(1, timeStr.length - 1).split(":"); for (let i = 0; i < nums.length; ++i) { result = result * 60 + +nums[i]; } return result; } /** x s => [xx:xx.xx] */ private timeToLrctime(sec: number) { const min = Math.floor(sec / 60); sec = sec - min * 60; const secInt = Math.floor(sec); const secFloat = sec - secInt; return `[${min.toFixed(0).padStart(2, "0")}:${secInt .toString() .padStart(2, "0")}.${secFloat.toFixed(2).slice(2)}]`; } private parseMetaImpl(metaStr: string) { if (metaStr === "") { return {}; } const metaArr = metaStr.match(metaReg) ?? []; const meta: any = {}; let k, v; for (const m of metaArr) { k = m.substring(1, m.indexOf(":")); v = m.substring(k.length + 2, m.length - 1); if (k === "offset") { meta[k] = +v / 1000; } else { meta[k] = v; } } return meta; } private parseLyricImpl(raw: string) { raw = raw.trim(); const rawLrcItems: Array = []; const rawLrcs = raw.split(timeReg) ?? []; const rawTimes = raw.match(timeReg) ?? []; const len = rawTimes.length; const meta = this.parseMetaImpl(rawLrcs[0].trim()); rawLrcs.shift(); let counter = 0; let j, lrc; for (let i = 0; i < len; ++i) { counter = 0; while (rawLrcs[0] === "") { ++counter; rawLrcs.shift(); } lrc = rawLrcs[0]?.trim?.() ?? ""; for (j = i; j < i + counter; ++j) { rawLrcItems.push({ time: this.parseTime(rawTimes[j]), lrc, index: j, }); } i += counter; if (i < len) { rawLrcItems.push({ time: this.parseTime(rawTimes[i]), lrc, index: j, }); } rawLrcs.shift(); } let lrcItems = rawLrcItems.sort((a, b) => a.time - b.time); if (lrcItems.length === 0 && raw.length) { lrcItems = raw.split("\n").map((_, index) => ({ time: 0, lrc: _, index, })); } return { lrcItems, meta, }; } } ================================================ FILE: src/renderer/utils/preload-util.ts ================================================ ================================================ FILE: src/renderer/utils/raf2.ts ================================================ export default function (fn: (...args: any) => void) { requestAnimationFrame(() => { requestAnimationFrame(fn); }); } ================================================ FILE: src/renderer/utils/search-history.ts ================================================ import { getUserPreferenceIDB, setUserPreferenceIDB } from "./user-perference"; import AppConfig from "@shared/app-config/renderer"; export async function getSearchHistory() { return (await getUserPreferenceIDB("searchHistory")) ?? []; } export async function addSearchHistory(searchItem: string) { const oldSearchHistory = await getSearchHistory(); const maxHistoryLen = AppConfig.getConfig("normal.maxHistoryLength"); const newSearchHistory = [ searchItem, ...oldSearchHistory.filter((item) => item !== searchItem), ].slice(0, maxHistoryLen); await setUserPreferenceIDB("searchHistory", newSearchHistory); } export async function removeSearchHistory(searchItem: string) { const oldSearchHistory = await getSearchHistory(); const newSearchHistory = oldSearchHistory.filter( (item) => item !== searchItem, ); await setUserPreferenceIDB("searchHistory", newSearchHistory); } export async function clearSearchHistory() { await setUserPreferenceIDB("searchHistory", []); } ================================================ FILE: src/renderer/utils/user-perference.ts ================================================ import { safeParse } from "@/common/safe-serialization"; import Dexie, { Table } from "dexie"; import EventEmitter from "eventemitter3"; import { useEffect, useState } from "react"; const basicType = ["number", "string", "boolean", "null", "undefined"]; const ee = new EventEmitter(); enum EvtNames { USER_PREFERENCE_UPDATE = "USER_PREFERENCE_UPDATE", } export function setUserPreference( key: K, value: IUserPreference.IType[K], ) { try { let newValue; if (typeof value in basicType) { newValue = value as any; } else { newValue = JSON.stringify(value); } localStorage.setItem(key, newValue as any); ee.emit(EvtNames.USER_PREFERENCE_UPDATE, key, value); } catch { // 设置失败 } } export function removeUserPreference(key: keyof IUserPreference.IType) { try { localStorage.removeItem(key); ee.emit(EvtNames.USER_PREFERENCE_UPDATE, key, null); } catch {} } export function getUserPreference( key: K, ): IUserPreference.IType[K] | null { let rawData = null; try { rawData = localStorage.getItem(key); if (!rawData) { return null; } return JSON.parse(rawData); } catch { return rawData as any; } } export function useUserPreference( key: K, ) { const [state, _setState] = useState(getUserPreference(key)); function setState(newState: IUserPreference.IType[K] | null) { setUserPreference(key, newState); } useEffect(() => { const updateFn = (updateKey: K, value: IUserPreference.IType[K] | null) => { if (key === updateKey) { _setState(value); } }; const updateFnStorage = (e: StorageEvent) => { if (e.key === key) { try { _setState(JSON.parse(e.newValue)); } catch { _setState(e.newValue as any); } } }; ee.on(EvtNames.USER_PREFERENCE_UPDATE, updateFn); window.addEventListener("storage", updateFnStorage); return () => { ee.off(EvtNames.USER_PREFERENCE_UPDATE, updateFn); window.removeEventListener("storage", updateFnStorage); }; }, []); return [state, setState] as const; } /** 比较大的数据 */ class UserPreferenceDB extends Dexie { // 歌单信息,其中musiclist只存有platform和id perference: Table>; constructor() { super("userPerferenceDB"); this.version(1.0).stores({ perference: "&key", }); } } const upDB = new UserPreferenceDB(); const dbKeyUpdateCbs = new Map< keyof IUserPreference.IDBType, Set<(...args: any) => void> >(); export async function setUserPreferenceIDB< K extends keyof IUserPreference.IDBType, >(key: K, value: IUserPreference.IDBType[K]) { try { await upDB.transaction("readwrite", upDB.perference, async () => { await upDB.perference.put({ key, value, }); }); const cb = dbKeyUpdateCbs.get(key); cb?.forEach((it) => it?.(value)); return true; } catch { return false; } } export async function getUserPreferenceIDB< K extends keyof IUserPreference.IDBType, >(key: K): Promise { try { return ( ( await upDB.transaction("readonly", upDB.perference, async () => { return await upDB.perference.get(key); }) )?.value ?? null ); } catch { return null; } } export function useUserPreferenceIDBValue< K extends keyof IUserPreference.IDBType, >(key: K) { const [state, setState] = useState(null); useEffect(() => { (async () => { try { const result = await getUserPreferenceIDB(key); setState(result); } catch { } finally { if (dbKeyUpdateCbs.has(key)) { dbKeyUpdateCbs.get(key).add(setState); } else { dbKeyUpdateCbs.set(key, new Set([setState])); } } })(); }, []); return state; } ================================================ FILE: src/renderer-lrc/document/bootstrap.ts ================================================ import AppConfig from "@shared/app-config/renderer"; import messageBus from "@shared/message-bus/renderer/extension"; export default async function () { // let prevTimestamp = 0; await AppConfig.setup(); messageBus.subscribeAppState(["playerState", "musicItem", "repeatMode", "parsedLrc", "lyricText", "fullLyric", "progress"]); messageBus.sendCommand("SyncAppState"); } ================================================ FILE: src/renderer-lrc/document/index.html ================================================ Music Free
    ================================================ FILE: src/renderer-lrc/document/index.tsx ================================================ import ReactDOM from "react-dom/client"; import bootstrap from "./bootstrap"; import LyricWindowPage from "../pages"; import { useEffect } from "react"; import "animate.css"; import "rc-slider/assets/index.css"; import "react-toastify/dist/ReactToastify.css"; import "./styles/index.scss"; // 全局样式 import WindowDrag from "@shared/window-drag/renderer"; bootstrap().then(() => { ReactDOM.createRoot(document.getElementById("root")).render(); }); function Root() { useEffect(() => { WindowDrag.injectHandler(); }, []); return ; } ================================================ FILE: src/renderer-lrc/document/styles/index.scss ================================================ html, body, #root { margin: 0; width: 100vw; height: 100vh; overflow: hidden; } ::-webkit-scrollbar { width: var(--scrollbarWidth, 8px); height: 8px; } ::-webkit-scrollbar-thumb { background-color: #b2b2b2; border-radius: 8px; } ::-webkit-scrollbar-thumb:hover { background-color: #a0a0a0; } ::-webkit-scrollbar-thumb:active { background-color: #a0a0a0; } ::-webkit-scrollbar-track { background-color: rgba(0, 0, 0, 0.1); } .blur10 { backdrop-filter: blur(10px); } .opacity-button { opacity: 0.6; &:hover { opacity: 1; } } .highlight { color: var(--primaryColor) !important; } input { outline: none; font-size: 1rem; padding: 0.4rem 0.6rem; border-radius: 2px; border: 1px solid var(--dividerColor); box-sizing: border-box; &::placeholder { opacity: 0.6; } } div[role="button"] { cursor: pointer; user-select: none; &[data-disabled]:not([data-disabled="false"]) { cursor: default; opacity: 0.5; pointer-events: none; } &[data-type="primaryButton"] { background-color: var(--primaryColor); font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: white; width: fit-content; line-height: 1em; } &[data-type="normalButton"] { font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: var(--textColor); border: 1px solid currentColor; width: fit-content; line-height: 1em; } &[data-type="dangerButton"] { font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: #fc5f5f; border: 1px solid currentColor; width: fit-content; line-height: 1em; &[data-fill="true"] { color: white; background: #fc5f5f; } } } .divider { width: 100%; height: 1px; background-color: var(--dividerColor); margin-top: 12px; margin-bottom: 12px; } ================================================ FILE: src/renderer-lrc/pages/index.scss ================================================ .container { position: relative; width: 100%; height: 100%; cursor: default; user-select: none; &:not(.lock-lyric):hover { background-color: rgba($color: #000000, $alpha: 0.2); } &::after { display: block; content: ''; height: 14px; } & .operation-outer-container { width: 100%; height: 46px; display: flex; align-items: flex-end; justify-content: center; } & .content-container { width: 100%; height: calc(100% - 60px); display: flex; align-items: center; justify-content: center; & .lyric-text-row { -webkit-text-stroke: 1px #b48f1d; color: white; font-size: 48px; white-space: nowrap; position: absolute; } } & .operation-container { height: 28px; display: flex; align-items: center; justify-content: center; column-gap: 16px; & .operation-button { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: white; cursor: pointer; filter: drop-shadow(0px 0px 2px rgba($color: #000000, $alpha: 0.6)); & svg { width: 24px; height: 24px; } } } } ================================================ FILE: src/renderer-lrc/pages/index.tsx ================================================ import "./index.scss"; import classNames from "@/renderer/utils/classnames"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Condition from "@/renderer/components/Condition"; import SvgAsset from "@/renderer/components/SvgAsset"; import { PlayerState } from "@/common/constant"; import getTextWidth from "@/renderer/utils/get-text-width"; import useAppConfig from "@/hooks/useAppConfig"; import { appWindowUtil } from "@shared/utils/renderer"; import AppConfig from "@shared/app-config/renderer"; import messageBus, { useAppStatePartial } from "@shared/message-bus/renderer/extension"; import { IAppState } from "@shared/message-bus/type"; export default function LyricWindowPage() { const currentMusic = useAppStatePartial("musicItem"); const playerState = useAppStatePartial("playerState"); const lockLyric = useAppConfig("lyric.lockLyric"); const [showOperations, setShowOperations] = useState(false); const mouseOverTimerRef = useRef(null); useEffect(() => { if (lockLyric) { setShowOperations(false); } }, [lockLyric]); return (
    { if (!lockLyric || mouseOverTimerRef.current) { if (!lockLyric) { setShowOperations(true); } return; } mouseOverTimerRef.current = window.setTimeout(() => { setShowOperations(true); clearTimeout(mouseOverTimerRef.current); mouseOverTimerRef.current = null; }, 1000); }} onMouseLeave={() => { setShowOperations(false); if (mouseOverTimerRef.current) { clearTimeout(mouseOverTimerRef.current); mouseOverTimerRef.current = null; } }} >
    { AppConfig.setConfig({ "lyric.lockLyric": false, }); }} onMouseOver={() => { appWindowUtil.ignoreMouseEvent(false); }} onMouseLeave={() => { appWindowUtil.ignoreMouseEvent(true); }} >
    } >
    { messageBus.sendCommand("SkipToPrevious"); }} >
    { if (currentMusic) { messageBus.sendCommand("TogglePlayerState"); } }} >
    { messageBus.sendCommand("SkipToNext"); }} >
    { AppConfig.setConfig({ "lyric.lockLyric": true, }); }} >
    { appWindowUtil.setLyricWindow(false); }} >
    ); } function LyricContent() { const currentMusic = useAppStatePartial("musicItem"); const currentLyric = useAppStatePartial("parsedLrc"); const currentFullLyric = useAppStatePartial("fullLyric"); const fontDataConfig = useAppConfig("lyric.fontData"); const fontSizeConfig = useAppConfig("lyric.fontSize"); const fontColorConfig = useAppConfig("lyric.fontColor"); const fontStrokeConfig = useAppConfig("lyric.strokeColor"); const [enableTransition, setEnableTransition] = useState(false); const textWidth = useMemo(() => { if (currentLyric?.lrc) { return getTextWidth(currentLyric?.lrc, { fontSize: fontSizeConfig ?? 48, fontFamily: fontDataConfig?.family || undefined, }); } else if (currentMusic) { return getTextWidth(`${currentMusic.title} - ${currentMusic.artist}`, { fontSize: fontSizeConfig ?? 48, fontFamily: fontDataConfig?.family || undefined, }); } return 0; }, [currentLyric, fontDataConfig, fontSizeConfig, currentMusic]); const [left, setLeft] = useState(null); useLayoutEffect(() => { if (textWidth > window.innerWidth) { setEnableTransition(false); setLeft(0); } else { setLeft(null); } }, [textWidth]); useLayoutEffect(() => { const callback = (_: any, patch: IAppState) => { if (!patch.progress) { return; } if (textWidth > window.innerWidth) { if (currentLyric && currentLyric.index > -1 && currentFullLyric) { const nextLyric = currentFullLyric[currentLyric.index + 1]; if (nextLyric && (nextLyric.time > currentLyric.time)) { const diff = nextLyric.time - currentLyric.time; const virtualPointer = (patch.progress - currentLyric.time) / diff * textWidth; if (virtualPointer > window.innerWidth * 0.5) { setEnableTransition(true); setLeft(-Math.min((virtualPointer - window.innerWidth * 0.5) * 1.1, textWidth - window.innerWidth)); return; } } } setEnableTransition(false); setLeft(0); } else { setEnableTransition(false); setLeft(null); } }; messageBus.onStateChange(callback); return () => { messageBus.offStateChange(callback); }; }, [textWidth, currentFullLyric, currentLyric]); return (
    {currentLyric?.lrc ?? (currentMusic ? `${currentMusic.title} - ${currentMusic.artist}` : "暂无歌词")}
    ); } ================================================ FILE: src/renderer-minimode/document/bootstrap.ts ================================================ import { setupI18n } from "@/shared/i18n/renderer"; import AppConfig from "@shared/app-config/renderer"; import messageBus from "@shared/message-bus/renderer/extension"; export default async function () { // TODO: broadcast await AppConfig.setup(); await setupI18n(); messageBus.subscribeAppState(["playerState", "musicItem", "repeatMode", "parsedLrc", "lyricText"]); messageBus.sendCommand("SyncAppState"); } ================================================ FILE: src/renderer-minimode/document/index.html ================================================ Music Free
    ================================================ FILE: src/renderer-minimode/document/index.tsx ================================================ import ReactDOM from "react-dom/client"; import bootstrap from "./bootstrap"; import MinimodePage from "../pages"; import { useEffect } from "react"; import "animate.css"; import "rc-slider/assets/index.css"; import "react-toastify/dist/ReactToastify.css"; import "./styles/index.scss"; // 全局样式 import WindowDrag from "@shared/window-drag/renderer"; bootstrap().then(() => { ReactDOM.createRoot(document.getElementById("root")).render(); }); function Root() { useEffect(() => { WindowDrag.injectHandler(); }, []); return ; } ================================================ FILE: src/renderer-minimode/document/styles/index.scss ================================================ html, body, #root { margin: 0; width: 100vw; height: 100vh; overflow: hidden; } ::-webkit-scrollbar { width: var(--scrollbarWidth, 8px); height: 8px; } ::-webkit-scrollbar-thumb { background-color: #b2b2b2; border-radius: 8px; } ::-webkit-scrollbar-thumb:hover { background-color: #a0a0a0; } ::-webkit-scrollbar-thumb:active { background-color: #a0a0a0; } ::-webkit-scrollbar-track { background-color: rgba(0, 0, 0, 0.1); } .blur10 { backdrop-filter: blur(10px); } .opacity-button { opacity: 0.6; &:hover { opacity: 1; } } .highlight { color: var(--primaryColor) !important; } input { outline: none; font-size: 1rem; padding: 0.4rem 0.6rem; border-radius: 2px; border: 1px solid var(--dividerColor); box-sizing: border-box; &::placeholder { opacity: 0.6; } } div[role="button"] { cursor: pointer; user-select: none; &[data-disabled]:not([data-disabled="false"]) { cursor: default; opacity: 0.5; pointer-events: none; } &[data-type="primaryButton"] { background-color: var(--primaryColor); font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: white; width: fit-content; line-height: 1em; } &[data-type="normalButton"] { font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: var(--textColor); border: 1px solid currentColor; width: fit-content; line-height: 1em; } &[data-type="dangerButton"] { font-size: 1em; padding: 0.4em 1em; border-radius: 1.6em; color: #fc5f5f; border: 1px solid currentColor; width: fit-content; line-height: 1em; &[data-fill="true"] { color: white; background: #fc5f5f; } } } .divider { width: 100%; height: 1px; background-color: var(--dividerColor); margin-top: 12px; margin-bottom: 12px; } ================================================ FILE: src/renderer-minimode/pages/index.scss ================================================ .minimode-page-container { width: 100%; height: 100%; background-color: transparent; user-select: none; & .minimode-header-container { width: 100%; height: 72px; box-sizing: border-box; padding: 8px; display: flex; gap: 12px; align-items: center; border-radius: 6px; & .mini-mode-header-background-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: -2; background-color: #333333; } & .mini-mode-header-background { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-size: cover; background-position: center; z-index: -1; opacity: 0.6; transition: all 300ms ease-in-out; filter: blur(15px); } & .album-container { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; } & .body-container { flex: 1; height: 100%; font-size: 13px; & .text-container { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; color: white; & span { // 最多两行 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; } } & .options-container { display: flex; align-items: center; justify-content: center; gap: 20px; height: 100%; padding-right: 48px; position: relative; & .close-button { position: absolute; right: 0; top: 0; color: white; & svg { width: 16px; height: 16px; } } & .option-item { color: white; & svg { width: 28px; height: 28px; } opacity: 0.8; } } } } } ================================================ FILE: src/renderer-minimode/pages/index.tsx ================================================ import { useState } from "react"; import SvgAsset from "@/renderer/components/SvgAsset"; import { PlayerState } from "@/common/constant"; import albumImg from "@/assets/imgs/album-cover.jpg"; import "./index.scss"; import { useTranslation } from "react-i18next"; import { useUserPreference } from "@/renderer/utils/user-perference"; import { appWindowUtil } from "@shared/utils/renderer"; import messageBus, { useAppStatePartial } from "@shared/message-bus/renderer/extension"; export default function MinimodePage() { const [hover, setHover] = useState(false); const currentMusicItem = useAppStatePartial("musicItem"); const playerState = useAppStatePartial("playerState"); const lyricItem = useAppStatePartial("parsedLrc"); const { t } = useTranslation(); const [showTranslation] = useUserPreference("showTranslation"); const textContent = (
    {lyricItem?.lrc || currentMusicItem?.title || t("media.unknown_title")} {showTranslation ? {lyricItem?.translation} : null}
    ); const options = (
    { appWindowUtil.setMinimodeWindow(false); appWindowUtil.showMainWindow(); }} >
    { messageBus.sendCommand("SkipToPrevious"); }} >
    { messageBus.sendCommand( "TogglePlayerState", ); }} >
    { messageBus.sendCommand("SkipToNext"); }} >
    ); return (
    { setHover(true); }} onMouseLeave={() => { setHover(false); }} >
    { appWindowUtil.showMainWindow(); }} >
    {hover ? options : textContent}
    ); } ================================================ FILE: src/shared/app-config/default-app-config.ts ================================================ import { defaultFont } from "@/common/constant"; import { IAppConfig } from "@/types/app-config"; const _defaultAppConfig: IAppConfig = { "$schema-version": 1, "playMusic.whenQualityMissing": "lower", "playMusic.defaultQuality": "standard", "playMusic.clickMusicList": "replace", "playMusic.caseSensitiveInSearch": false, "playMusic.playError": "skip", "playMusic.whenDeviceRemoved": "play", "normal.taskbarThumb": "window", "normal.closeBehavior": "minimize", "normal.checkUpdate": true, "normal.maxHistoryLength": 30, "download.defaultQuality": "standard", "download.whenQualityMissing": "lower", "lyric.enableDesktopLyric": false, "lyric.alwaysOnTop": false, "lyric.lockLyric": false, "lyric.fontData": defaultFont, "lyric.fontColor": "#fff", "lyric.strokeColor": "#b48f1d", "lyric.fontSize": 54, "shortCut.enableLocal": true, "shortCut.enableGlobal": false, "download.concurrency": 5, "normal.musicListColumnsShown": [], "backup.resumeBehavior": "append", "normal.language": "zh-CN", }; export default _defaultAppConfig; ================================================ FILE: src/shared/app-config/main.ts ================================================ import path from "path"; import { app, ipcMain } from "electron"; import originalFs from "fs"; import fs from "fs/promises"; import { rimraf } from "rimraf"; import { IAppConfig } from "@/types/app-config"; import { IWindowManager } from "@/types/main/window-manager"; import logger from "@shared/logger/main"; import _defaultAppConfig from "@shared/app-config/default-app-config"; class AppConfig { private _configPath: string; private windowManager: IWindowManager; private config: IAppConfig; private onAppConfigUpdatedCallbacks = new Set<(patch: IAppConfig, config: IAppConfig, from: "main" | "renderer") => void>(); get configPath() { if (!this._configPath) { this._configPath = path.resolve(app.getPath("userData"), "config.json"); } return this._configPath; } private async checkPath() { // 1. Check dir const configDirPath = app.getPath("userData"); try { const res = await fs.stat(configDirPath); if (!res.isDirectory()) { await rimraf(configDirPath); throw new Error("Not a valid path"); } } catch { await fs.mkdir(configDirPath, { recursive: true, }); } // 2. Check file try { const res = await fs.stat(this.configPath); if (!res.isFile()) { await rimraf(this.configPath); throw new Error("Not a valid path"); } } catch { await fs.writeFile(this.configPath, JSON.stringify(_defaultAppConfig, undefined, 4), "utf-8"); } } async setup(windowManager: IWindowManager) { this.windowManager = windowManager; await this.checkPath(); await this.loadConfig(); // Bind events // sync config ipcMain.handle("@shared/app-config/sync-app-config", () => { return this.config; }); ipcMain.on("@shared/app-config/set-app-config", (_rawEvt, data: IAppConfig) => { /** * data: {key: value} */ this._setConfig(data, "renderer"); }); ipcMain.on("@shared/app-config/reset", () => { this.reset(); }); } public onConfigUpdated(callback: (patch: IAppConfig, config: IAppConfig, from: "main" | "renderer") => void) { this.onAppConfigUpdatedCallbacks.add(callback); } public offConfigUpdated(callback: (patch: IAppConfig, config: IAppConfig, from: "main" | "renderer") => void) { this.onAppConfigUpdatedCallbacks.delete(callback); } async migrateOldVersionConfig() { if (this.config["$schema-version"] >= 0) { return; } // 1. 升级到v1 try { const oldConfig = this.config as any; const newConfig: any = { "normal.closeBehavior": oldConfig.normal?.closeBehavior === "exit" ? "exit_app" : oldConfig.normal?.closeBehavior, "normal.maxHistoryLength": oldConfig.normal?.maxHistoryLength, "normal.checkUpdate": oldConfig.normal?.checkUpdate, "normal.taskbarThumb": oldConfig.normal?.taskbarThumb, "normal.musicListColumnsShown": oldConfig.normal?.musicListColumnsShown, "normal.language": oldConfig.normal?.language, "playMusic.caseSensitiveInSearch": oldConfig.playMusic?.caseSensitiveInSearch, "playMusic.defaultQuality": oldConfig.playMusic?.defaultQuality, "playMusic.whenQualityMissing": oldConfig.playMusic?.whenQualityMissing, "playMusic.clickMusicList": oldConfig.playMusic?.clickMusicList, "playMusic.playError": oldConfig.playMusic?.playError, "playMusic.audioOutputDevice": oldConfig.playMusic?.audioOutputDevice, "playMusic.whenDeviceRemoved": oldConfig.playMusic?.whenDeviceRemoved, "lyric.enableStatusBarLyric": oldConfig.lyric?.enableStatusBarLyric, "lyric.enableDesktopLyric": oldConfig.lyric?.enableDesktopLyric, "lyric.alwaysOnTop": oldConfig.lyric?.alwaysOnTop, "lyric.lockLyric": oldConfig.lyric?.lockLyric, "lyric.fontData": oldConfig.lyric?.fontData, "lyric.fontColor": oldConfig.lyric?.fontColor, "lyric.fontSize": oldConfig.lyric?.fontSize, "lyric.strokeColor": oldConfig.lyric?.strokeColor, "shortCut.enableLocal": oldConfig.shortCut?.enableLocal, "shortCut.enableGlobal": oldConfig.shortCut?.enableGlobal, "shortCut.shortcuts": { ...oldConfig.shortCut?.shortcuts, "toggle-main-window-visible": { local: null, global: null }, }, "download.path": oldConfig.download?.path, "download.defaultQuality": oldConfig.download?.defaultQuality, "download.whenQualityMissing": oldConfig.download?.whenQualityMissing, "download.concurrency": oldConfig.download?.concurrency, "plugin.autoUpdatePlugin": oldConfig.plugin?.autoUpdatePlugin, "plugin.notCheckPluginVersion": oldConfig.plugin?.notCheckPluginVersion, "network.proxy.enabled": oldConfig.network?.proxy?.enabled, "network.proxy.host": oldConfig.network?.proxy?.host, "network.proxy.port": oldConfig.network?.proxy?.port, "network.proxy.username": oldConfig.network?.proxy?.username, "network.proxy.password": oldConfig.network?.proxy?.password, "backup.resumeBehavior": oldConfig.backup?.resumeBehavior, "backup.webdav.url": oldConfig.backup?.webdav?.url, "backup.webdav.username": oldConfig.backup?.webdav?.username, "backup.webdav.password": oldConfig.backup?.webdav?.password, "localMusic.watchDir": oldConfig.localMusic?.watchDir, "private.lyricWindowPosition": oldConfig.private?.lyricWindowPosition, "private.minimodeWindowPosition": oldConfig.private?.minimodeWindowPosition, "private.pluginMeta": oldConfig.private?.pluginMeta, "private.minimode": oldConfig.private?.minimode, }; this.config = newConfig; for (const k in _defaultAppConfig) { if (newConfig[k] === null || newConfig[k] === undefined) { // @ts-ignore newConfig[k] = _defaultAppConfig[k]; } } const rawConfig = JSON.stringify(newConfig, undefined, 4); originalFs.writeFileSync(this.configPath, rawConfig, "utf-8"); } catch (e) { logger.logError("迁移旧版配置失败", e); } } async loadConfig() { try { if (this.config) { return { ..._defaultAppConfig, ...this.config }; } else { const rawConfig = await fs.readFile(this.configPath, "utf8"); this.config = JSON.parse(rawConfig); // 升级旧版设置 await this.migrateOldVersionConfig(); this.config = { ..._defaultAppConfig, ...this.config, }; } } catch (e) { if (e.message === "Unexpected end of JSON input" || e.code === "EISDIR") { // JSON 解析异常 / 非文件 await rimraf(this.configPath); await this.checkPath(); } else if (e.code === "ENOENT") { // 文件不存在 await this.checkPath(); } this.config = { ..._defaultAppConfig }; } return this.config; } public getAllConfig() { return this.config; } public reset() { this.config = {}; this.setConfig({}); } public getConfig(key: T): IAppConfig[T] { return this.config[key]; } public setConfig(data: IAppConfig) { this._setConfig(data, "main"); } private _setConfig(data: IAppConfig, from: "main" | "renderer") { try { // 1. Merge old one this.config = { ..._defaultAppConfig, ...this.config, ...data }; // 2. Save to file const rawConfig = JSON.stringify(this.config, undefined, 4); originalFs.writeFileSync(this.configPath, rawConfig, "utf-8"); // 3. Notify to all windows this.windowManager.getAllWindows().forEach((window) => { window.webContents.send("@shared/app-config/update-app-config", data); }); this.onAppConfigUpdatedCallbacks.forEach((callback) => { callback(data, this.config, from); }); } catch (e) { logger.logError("设置配置失败", e); } } } export default new AppConfig(); ================================================ FILE: src/shared/app-config/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; async function syncConfig() { return await ipcRenderer.invoke("@shared/app-config/sync-app-config"); } function setConfig(config: any) { return ipcRenderer.send("@shared/app-config/set-app-config", config); } function reset() { return ipcRenderer.send("@shared/app-config/reset"); } function onConfigUpdate(callback: (patch: any) => void) { ipcRenderer.on("@shared/app-config/update-app-config", (_event, patch) => { callback(patch); }); } const mod = { syncConfig, setConfig, onConfigUpdate, reset, }; contextBridge.exposeInMainWorld("@shared/app-config", mod); ================================================ FILE: src/shared/app-config/renderer.ts ================================================ import { IAppConfig } from "@/types/app-config"; import defaultAppConfig from "@shared/app-config/default-app-config"; interface IMod { syncConfig(): Promise; setConfig(config: IAppConfig): void; onConfigUpdate(callback: (config: IAppConfig) => void): void; reset(): void; } const mod = window["@shared/app-config" as any] as unknown as IMod; class AppConfig { private config: IAppConfig = {}; public initialized = false; private updateCallbacks: Set<(patch: IAppConfig, config: IAppConfig) => void> = new Set(); private notifyCallbacks(patch: IAppConfig) { for (const callback of this.updateCallbacks) { callback(patch, this.config); } } async setup() { this.initialized = true; this.config = await mod.syncConfig(); this.notifyCallbacks(this.config); mod.onConfigUpdate((patch) => { this.config = { ...defaultAppConfig, ...this.config, ...patch }; this.notifyCallbacks(patch); }); } public onConfigUpdate(callback: (patch: IAppConfig, config: IAppConfig) => void) { this.updateCallbacks.add(callback); } public offConfigUpdate(callback: (patch: IAppConfig, config: IAppConfig) => void) { this.updateCallbacks.delete(callback); } public getAllConfig() { return this.config; } public getConfig(key: T): IAppConfig[T] { return this.config[key]; } public setConfig(data: IAppConfig) { mod.setConfig(data); } public reset() { mod.reset(); this.config = defaultAppConfig; this.notifyCallbacks(this.config); } } export default new AppConfig(); ================================================ FILE: src/shared/database/main.ts ================================================ export default {}; ================================================ FILE: src/shared/database/preload-backup.ts ================================================ /** * 数据库模块 * * 此模块负责加载数据库相关的功能,提供渲染进程需要的业务逻辑。 */ import { app } from "electron"; import path from "node:path"; import Database from "better-sqlite3"; const appDbPath = path.resolve(app.getPath("userData"), "./app-database/database.db"); const database = new Database(appDbPath); database.pragma("journal_mode = WAL"); database.pragma("foreign_keys = ON"); // 启用外键支持 database.pragma("synchronous = NORMAL"); // WAL模式下推荐设置 // 数据库版本号 const DATABASE_LATEST_VERSION = 1; // 创建初始表结构 function createInitialTables() { // 创建歌单表(IMusicSheetModel) database.exec(` CREATE TABLE IF NOT EXISTS LocalMusicSheets ( platform TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, artwork TEXT, description TEXT, worksNum INTEGER DEFAULT 0, playCount INTEGER DEFAULT 0, createAt INTEGER, artist TEXT, _raw TEXT NOT NULL, -- (存储原始JSON数据) _sortIndex REAL, -- $$sortIndex -- 联合主键 PRIMARY KEY (platform, id) ); `); // 创建音乐项表(IMusicItemModel) database.exec(` CREATE TABLE IF NOT EXISTS LocalMusicItems ( _key INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, id TEXT NOT NULL, artist TEXT NOT NULL, title TEXT NOT NULL, duration REAL, album TEXT, artwork TEXT, _timestamp INTEGER NOT NULL, _raw TEXT NOT NULL, -- 替代 $$raw _sortIndex REAL, _musicSheetId TEXT NOT NULL, -- 替代 $$musicSheetId _musicSheetPlatform TEXT NOT NULL, -- 替代 $$musicSheetPlatform -- 添加复合唯一约束防止同一歌单重复添加相同歌曲 UNIQUE (_musicSheetPlatform, _musicSheetId, platform, id), -- 外键引用歌单表的联合主键 FOREIGN KEY (_musicSheetPlatform, _musicSheetId) REFERENCES LocalMusicSheets(platform, id) ON DELETE CASCADE ON UPDATE CASCADE ); `); // 创建索引优化查询性能 database.exec("CREATE INDEX IF NOT EXISTS idx_items_coreid ON LocalMusicItems(platform, id)"); database.exec("CREATE INDEX IF NOT EXISTS idx_items_sheet ON LocalMusicItems(_musicSheetPlatform, _musicSheetId)"); database.exec("CREATE INDEX IF NOT EXISTS idx_sheets_platform ON LocalMusicSheets(platform)"); database.exec("CREATE INDEX IF NOT EXISTS idx_items_artist ON LocalMusicItems(artist)"); database.exec("CREATE INDEX IF NOT EXISTS idx_sheets_sort ON LocalMusicSheets(_sortIndex)"); // 创建star的歌单表(IMusicSheetModel) database.exec(` CREATE TABLE IF NOT EXISTS StarredMusicSheets ( platform TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, artwork TEXT, description TEXT, worksNum INTEGER DEFAULT 0, playCount INTEGER DEFAULT 0, createAt INTEGER, artist TEXT, _raw TEXT NOT NULL, -- $$raw (存储原始JSON数据) _sortIndex REAL, -- $$sortIndex -- 联合主键 PRIMARY KEY (platform, id) ); `); } function migrateDatabase() { let currentVersion = database.pragma("user_version", { simple: true }) as number; if (currentVersion >= DATABASE_LATEST_VERSION) { return; } if (!currentVersion) { currentVersion = 0; } // 在事务中执行升级 const upgrade = database.transaction(() => { for (let version = currentVersion + 1; version <= DATABASE_LATEST_VERSION; version++) { switch (version) { case 1: createInitialTables(); break; // 未来的版本升级可以在这里添加 // case 2: // upgradeToVersion2(); // break; default: throw new Error(`Unknown database version: ${version}`); } database.pragma(`user_version = ${version}`); } }); upgrade(); } migrateDatabase(); //////////////////// 歌单增删查改 class LocalMusicSheetDB { private static readonly SORT_BASE = 1000; // 排序基础值 private static readonly SORT_INCREMENT = 1000; // 排序增量 private static readonly MIN_SORT_INTERVAL = 0.000001; // 最小排序间隔,低于此值需要重新均衡 /** * 添加歌单 * @param musicSheet 歌单数据 * @returns 是否添加成功 */ static addMusicSheet(musicSheet: IDataBaseModel.IMusicSheetModel): boolean { try { const insertStmt = database.prepare(` INSERT INTO LocalMusicSheets (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // 如果没有指定排序索引,设置为当前最大值+增量 let sortIndex = musicSheet._sortIndex; if (sortIndex === undefined || sortIndex === null) { const maxSortStmt = database.prepare("SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets"); const result = maxSortStmt.get() as { maxSort: number | null }; sortIndex = (result.maxSort || 0) + this.SORT_INCREMENT; } const result = insertStmt.run( musicSheet.platform, musicSheet.id, musicSheet.title, musicSheet.artwork || null, musicSheet.description || null, musicSheet.worksNum || 0, musicSheet.playCount || 0, musicSheet.createAt || Date.now(), musicSheet.artist || null, musicSheet._raw, sortIndex, ); return result.changes > 0; } catch (error) { console.error("添加歌单失败:", error); return false; } } /** * 批量添加歌单 * @param musicSheets 歌单数据数组 * @returns 成功添加的数量 */ static batchAddMusicSheets(musicSheets: IDataBaseModel.IMusicSheetModel[]): number { if (!musicSheets.length) return 0; try { const transaction = database.transaction(() => { let successCount = 0; // 获取当前最大排序值 const maxSortStmt = database.prepare("SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets"); const result = maxSortStmt.get() as { maxSort: number | null }; let currentMaxSort = result.maxSort || 0; const insertStmt = database.prepare(` INSERT INTO LocalMusicSheets (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const sheet of musicSheets) { try { let sortIndex = sheet._sortIndex; if (sortIndex === undefined || sortIndex === null) { currentMaxSort += this.SORT_INCREMENT; sortIndex = currentMaxSort; } const insertResult = insertStmt.run( sheet.platform, sheet.id, sheet.title, sheet.artwork || null, sheet.description || null, sheet.worksNum || 0, sheet.playCount || 0, sheet.createAt || Date.now(), sheet.artist || null, sheet._raw, sortIndex, ); if (insertResult.changes > 0) { successCount++; } } catch (error) { console.warn(`批量添加歌单失败 (${sheet.platform}:${sheet.id}):`, error); } } return successCount; }); return transaction(); } catch (error) { console.error("批量添加歌单失败:", error); return 0; } } /** * 删除歌单 * @param platform 平台 * @param id 歌单ID * @returns 是否删除成功 */ static deleteMusicSheet(platform: string, id: string): boolean { try { const deleteStmt = database.prepare("DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const result = deleteStmt.run(platform, id); return result.changes > 0; } catch (error) { console.error("删除歌单失败:", error); return false; } } /** * 批量删除歌单 * @param sheets 要删除的歌单标识数组 {platform, id} * @returns 成功删除的数量 */ static batchDeleteMusicSheets(sheets: Array<{ platform: string; id: string }>): number { if (!sheets.length) return 0; try { const transaction = database.transaction(() => { let successCount = 0; const deleteStmt = database.prepare("DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?"); for (const sheet of sheets) { try { const result = deleteStmt.run(sheet.platform, sheet.id); if (result.changes > 0) { successCount++; } } catch (error) { console.warn(`批量删除歌单失败 (${sheet.platform}:${sheet.id}):`, error); } } return successCount; }); return transaction(); } catch (error) { console.error("批量删除歌单失败:", error); return 0; } } /** * 查询单个歌单 * @param platform 平台 * @param id 歌单ID * @returns 歌单数据或null */ static getMusicSheet(platform: string, id: string): IDataBaseModel.IMusicSheetModel | null { try { const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE platform = ? AND id = ? `); const result = selectStmt.get(platform, id) as any; if (!result) return null; return { platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, }; } catch (error) { console.error("查询歌单失败:", error); return null; } } /** * 查询所有歌单 * @param orderBy 排序字段,默认按_sortIndex排序 * @param order 排序方向,'ASC' 或 'DESC',默认ASC * @returns 歌单数组 */ static getAllMusicSheets(orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC"): IDataBaseModel.IMusicSheetModel[] { try { // 验证排序字段,防止SQL注入 const allowedFields = ["_sortIndex", "title", "createAt", "artist", "playCount"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets ORDER BY ${orderBy} ${order} `); const results = selectStmt.all() as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch (error) { console.error("查询所有歌单失败:", error); return []; } } /** * 按平台查询歌单 * @param platform 平台名称 * @param orderBy 排序字段 * @param order 排序方向 * @returns 歌单数组 */ static getMusicSheetsByPlatform(platform: string, orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC"): IDataBaseModel.IMusicSheetModel[] { try { const allowedFields = ["_sortIndex", "title", "createAt", "artist", "playCount"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE platform = ? ORDER BY ${orderBy} ${order} `); const results = selectStmt.all(platform) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch (error) { console.error("按平台查询歌单失败:", error); return []; } } /** * 更新歌单(部分更新) * @param platform 平台 * @param id 歌单ID * @param updates 要更新的字段 * @returns 是否更新成功 */ static updateMusicSheet( platform: string, id: string, updates: Partial>, ): boolean { try { if (Object.keys(updates).length === 0) { return false; } // 构建动态更新SQL const allowedFields = ["title", "artwork", "description", "worksNum", "playCount", "createAt", "artist", "_raw", "_sortIndex"]; const updateFields: string[] = []; const values: any[] = []; for (const [key, value] of Object.entries(updates)) { if (allowedFields.includes(key) && value !== undefined) { updateFields.push(`${key} = ?`); values.push(value); } } if (updateFields.length === 0) { return false; } values.push(platform, id); const updateStmt = database.prepare(` UPDATE LocalMusicSheets SET ${updateFields.join(", ")} WHERE platform = ? AND id = ? `); const result = updateStmt.run(...values); return result.changes > 0; } catch (error) { console.error("更新歌单失败:", error); return false; } } /** * 批量更新歌单 * @param updates 更新数据数组,每个元素包含 platform, id 和要更新的字段 * @returns 成功更新的数量 */ static batchUpdateMusicSheets( updates: Array<{ platform: string; id: string; data: Partial>; }>, ): number { if (!updates.length) return 0; try { const transaction = database.transaction(() => { let successCount = 0; for (const update of updates) { try { if (this.updateMusicSheet(update.platform, update.id, update.data)) { successCount++; } } catch (error) { console.warn(`批量更新歌单失败 (${update.platform}:${update.id}):`, error); } } return successCount; }); return transaction(); } catch (error) { console.error("批量更新歌单失败:", error); return 0; } } /** * 更新歌单排序 * @param platform 平台 * @param id 歌单ID * @param newSortIndex 新的排序索引 * @returns 是否更新成功 */ static updateMusicSheetSort(platform: string, id: string, newSortIndex: number): boolean { try { const updateStmt = database.prepare(` UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ? `); const result = updateStmt.run(newSortIndex, platform, id); return result.changes > 0; } catch (error) { console.error("更新歌单排序失败:", error); return false; } } /** * 在两个歌单之间插入新歌单(使用浮点数排序法) * @param platform 平台 * @param id 歌单ID * @param afterPlatform 插入位置前一个歌单的平台(null表示插入到开头) * @param afterId 插入位置前一个歌单的ID(null表示插入到开头) * @param beforePlatform 插入位置后一个歌单的平台(null表示插入到末尾) * @param beforeId 插入位置后一个歌单的ID(null表示插入到末尾) * @returns 是否更新成功 */ static insertMusicSheetBetween( platform: string, id: string, afterPlatform: string | null, afterId: string | null, beforePlatform: string | null, beforeId: string | null, ): boolean { try { let newSortIndex: number; if (!afterPlatform && !afterId) { // 插入到开头 const firstStmt = database.prepare("SELECT MIN(_sortIndex) as minSort FROM LocalMusicSheets"); const result = firstStmt.get() as { minSort: number | null }; newSortIndex = (result.minSort || this.SORT_BASE) - this.SORT_INCREMENT; } else if (!beforePlatform && !beforeId) { // 插入到末尾 const lastStmt = database.prepare("SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets"); const result = lastStmt.get() as { maxSort: number | null }; newSortIndex = (result.maxSort || this.SORT_BASE) + this.SORT_INCREMENT; } else { // 插入到中间 const afterStmt = database.prepare("SELECT _sortIndex FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const beforeStmt = database.prepare("SELECT _sortIndex FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const afterResult = afterStmt.get(afterPlatform, afterId) as { _sortIndex: number } | undefined; const beforeResult = beforeStmt.get(beforePlatform, beforeId) as { _sortIndex: number } | undefined; if (!afterResult || !beforeResult) { return false; } newSortIndex = (afterResult._sortIndex + beforeResult._sortIndex) / 2; // 检查是否需要重新均衡 if (Math.abs(beforeResult._sortIndex - afterResult._sortIndex) < this.MIN_SORT_INTERVAL) { this.rebalanceSortIndexes(); // 重新计算新的排序值 const newAfterResult = afterStmt.get(afterPlatform, afterId) as { _sortIndex: number } | undefined; const newBeforeResult = beforeStmt.get(beforePlatform, beforeId) as { _sortIndex: number } | undefined; if (newAfterResult && newBeforeResult) { newSortIndex = (newAfterResult._sortIndex + newBeforeResult._sortIndex) / 2; } } } return this.updateMusicSheetSort(platform, id, newSortIndex); } catch (error) { console.error("插入歌单排序失败:", error); return false; } } /** * 重新均衡所有歌单的排序索引 * 当排序间隔过小时调用此方法 */ static rebalanceSortIndexes(): boolean { try { const transaction = database.transaction(() => { // 获取所有歌单按当前排序 const selectStmt = database.prepare("SELECT platform, id FROM LocalMusicSheets ORDER BY _sortIndex ASC"); const sheets = selectStmt.all() as Array<{ platform: string; id: string }>; const updateStmt = database.prepare("UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?"); // 重新分配排序索引,每个间隔1000 sheets.forEach((sheet, index) => { const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT); updateStmt.run(newSortIndex, sheet.platform, sheet.id); }); return true; }); return transaction(); } catch (error) { console.error("重新均衡排序索引失败:", error); return false; } } /** * 获取歌单中的所有歌曲 * @param platform 歌单平台 * @param id 歌单ID * @param orderBy 排序字段,默认按_sortIndex排序 * @param order 排序方向 * @returns 歌曲数组 */ static getMusicItemsInSheet( platform: string, id: string, orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC", ): IDataBaseModel.IMusicItemModel[] { try { const allowedFields = ["_sortIndex", "title", "artist", "album", "_timestamp"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicItems WHERE _musicSheetPlatform = ? AND _musicSheetId = ? ORDER BY ${orderBy} ${order} `); const results = selectStmt.all(platform, id) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, artist: result.artist, title: result.title, duration: result.duration, album: result.album, artwork: result.artwork, _timestamp: result._timestamp, _raw: result._raw, _sortIndex: result._sortIndex, _musicSheetId: result._musicSheetId, _musicSheetPlatform: result._musicSheetPlatform, })); } catch (error) { console.error("获取歌单歌曲失败:", error); return []; } } /** * 获取歌单中歌曲的数量 * @param platform 歌单平台 * @param id 歌单ID * @returns 歌曲数量 */ static getMusicItemCountInSheet(platform: string, id: string): number { try { const countStmt = database.prepare(` SELECT COUNT(*) as count FROM LocalMusicItems WHERE _musicSheetPlatform = ? AND _musicSheetId = ? `); const result = countStmt.get(platform, id) as { count: number }; return result.count; } catch (error) { console.error("获取歌单歌曲数量失败:", error); return 0; } } /** * 检查歌单是否存在 * @param platform 平台 * @param id 歌单ID * @returns 是否存在 */ static existsMusicSheet(platform: string, id: string): boolean { try { const countStmt = database.prepare("SELECT COUNT(*) as count FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const result = countStmt.get(platform, id) as { count: number }; return result.count > 0; } catch (error) { console.error("检查歌单是否存在失败:", error); return false; } } /** * 搜索歌单 * @param keyword 搜索关键词 * @param searchFields 搜索字段数组,默认搜索title和artist * @returns 匹配的歌单数组 */ static searchMusicSheets( keyword: string, searchFields: string[] = ["title", "artist"], ): IDataBaseModel.IMusicSheetModel[] { try { if (!keyword.trim()) { return this.getAllMusicSheets(); } const allowedFields = ["title", "artist", "description"]; const validFields = searchFields.filter(field => allowedFields.includes(field)); if (validFields.length === 0) { validFields.push("title"); } const whereConditions = validFields.map(field => `${field} LIKE ?`).join(" OR "); const searchPattern = `%${keyword}%`; const params = validFields.map(() => searchPattern); const searchStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE ${whereConditions} ORDER BY _sortIndex ASC `); const results = searchStmt.all(...params) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch (error) { console.error("搜索歌单失败:", error); return []; } } /** * 批量移动歌单到指定位置(支持所有拖拽和排序场景) * @param selectedSheets 要移动的歌单标识数组 * @param targetPlatform 目标歌单的平台(null表示移动到开头/末尾) * @param targetId 目标歌单的ID(null表示移动到开头/末尾) * @param position 相对于目标歌单的位置:"before" | "after",默认"after" * @returns 成功移动的数量 * * @example * // 移动到开头 * batchMoveMusicSheets(sheets, null, null, "before") * * // 移动到末尾 * batchMoveMusicSheets(sheets, null, null, "after") * * // 移动到指定歌单之前 * batchMoveMusicSheets(sheets, "platform1", "id1", "before") * * // 移动到指定歌单之后 * batchMoveMusicSheets(sheets, "platform1", "id1", "after") */ static batchMoveMusicSheets( selectedSheets: Array<{ platform: string; id: string }>, targetPlatform: string | null = null, targetId: string | null = null, position: "before" | "after" = "after", ): number { if (!selectedSheets.length) return 0; try { const transaction = database.transaction(() => { // 获取所有歌单的排序信息 const allSheetsStmt = database.prepare(` SELECT platform, id, _sortIndex FROM LocalMusicSheets ORDER BY _sortIndex ASC `); const allSheets = allSheetsStmt.all() as Array<{ platform: string; id: string; _sortIndex: number; }>; // 分离要移动的歌单和剩余的歌单 const selectedSheetIds = new Set(selectedSheets.map(s => `${s.platform}:${s.id}`)); const sheetsToMove = allSheets.filter(s => selectedSheetIds.has(`${s.platform}:${s.id}`)); const remainingSheets = allSheets.filter(s => !selectedSheetIds.has(`${s.platform}:${s.id}`)); if (!sheetsToMove.length) return 0; // 计算插入位置 let insertIndex = 0; if (targetPlatform && targetId) { const targetIndex = remainingSheets.findIndex(s => s.platform === targetPlatform && s.id === targetId, ); insertIndex = targetIndex === -1 ? 0 : (position === "after" ? targetIndex + 1 : targetIndex); } else { insertIndex = position === "before" ? 0 : remainingSheets.length; } // 构建新的排序数组并重新分配排序索引 const newOrderedSheets = [...remainingSheets]; newOrderedSheets.splice(insertIndex, 0, ...sheetsToMove); const updateStmt = database.prepare(` UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ? `); let successCount = 0; newOrderedSheets.forEach((sheet, index) => { const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT); try { const result = updateStmt.run(newSortIndex, sheet.platform, sheet.id); if (result.changes > 0 && selectedSheetIds.has(`${sheet.platform}:${sheet.id}`)) { successCount++; } } catch (error) { console.warn(`批量移动歌单失败 (${sheet.platform}:${sheet.id}):`, error); } }); return successCount; }); return transaction(); } catch (error) { console.error("批量移动歌单失败:", error); return 0; } } } // 导出数据库实例和类 export { database, LocalMusicSheetDB }; ================================================ FILE: src/shared/database/preload.ts ================================================ /** * 数据库模块 * * 此模块负责加载数据库相关的功能,提供渲染进程需要的业务逻辑。 */ import { app } from "electron"; import path from "node:path"; import Database from "better-sqlite3"; const appDbPath = path.resolve(app.getPath("userData"), "./app-database/database.db"); const database = new Database(appDbPath); database.pragma("journal_mode = WAL"); database.pragma("foreign_keys = ON"); // 启用外键支持 database.pragma("synchronous = NORMAL"); // WAL模式下推荐设置 // 数据库版本号 const DATABASE_LATEST_VERSION = 1; // 创建初始表结构 function createInitialTables() { // 创建歌单表(IMusicSheetModel) database.exec(` CREATE TABLE IF NOT EXISTS LocalMusicSheets ( platform TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, artwork TEXT, description TEXT, worksNum INTEGER DEFAULT 0, playCount INTEGER DEFAULT 0, createAt INTEGER, artist TEXT, _raw TEXT NOT NULL, -- (存储原始JSON数据) _sortIndex REAL, -- $$sortIndex -- 联合主键 PRIMARY KEY (platform, id) ); `); // 创建音乐项表(IMusicItemModel) database.exec(` CREATE TABLE IF NOT EXISTS LocalMusicItems ( _key INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, id TEXT NOT NULL, artist TEXT NOT NULL, title TEXT NOT NULL, duration REAL, album TEXT, artwork TEXT, _timestamp INTEGER NOT NULL, _raw TEXT NOT NULL, -- 替代 $$raw _sortIndex REAL, _musicSheetId TEXT NOT NULL, -- 替代 $$musicSheetId _musicSheetPlatform TEXT NOT NULL, -- 替代 $$musicSheetPlatform -- 添加复合唯一约束防止同一歌单重复添加相同歌曲 UNIQUE (_musicSheetPlatform, _musicSheetId, platform, id), -- 外键引用歌单表的联合主键 FOREIGN KEY (_musicSheetPlatform, _musicSheetId) REFERENCES LocalMusicSheets(platform, id) ON DELETE CASCADE ON UPDATE CASCADE ); `); // 创建索引优化查询性能 database.exec("CREATE INDEX IF NOT EXISTS idx_items_coreid ON LocalMusicItems(platform, id)"); database.exec("CREATE INDEX IF NOT EXISTS idx_items_sheet ON LocalMusicItems(_musicSheetPlatform, _musicSheetId)"); database.exec("CREATE INDEX IF NOT EXISTS idx_sheets_platform ON LocalMusicSheets(platform)"); database.exec("CREATE INDEX IF NOT EXISTS idx_items_artist ON LocalMusicItems(artist)"); database.exec("CREATE INDEX IF NOT EXISTS idx_sheets_sort ON LocalMusicSheets(_sortIndex)"); // 创建star的歌单表(IMusicSheetModel) database.exec(` CREATE TABLE IF NOT EXISTS StarredMusicSheets ( platform TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, artwork TEXT, description TEXT, worksNum INTEGER DEFAULT 0, playCount INTEGER DEFAULT 0, createAt INTEGER, artist TEXT, _raw TEXT NOT NULL, -- $$raw (存储原始JSON数据) _sortIndex REAL, -- $$sortIndex -- 联合主键 PRIMARY KEY (platform, id) ); `); } function migrateDatabase() { let currentVersion = database.pragma("user_version", { simple: true }) as number; if (currentVersion >= DATABASE_LATEST_VERSION) { return; } if (!currentVersion) { currentVersion = 0; } // 在事务中执行升级 const upgrade = database.transaction(() => { for (let version = currentVersion + 1; version <= DATABASE_LATEST_VERSION; version++) { switch (version) { case 1: createInitialTables(); break; // 未来的版本升级可以在这里添加 // case 2: // upgradeToVersion2(); // break; default: throw new Error(`Unknown database version: ${version}`); } database.pragma(`user_version = ${version}`); } }); upgrade(); } migrateDatabase(); //////////////////// 歌单增删查改 class LocalMusicSheetDB { private static readonly SORT_BASE = 1000; // 排序基础值 private static readonly SORT_INCREMENT = 1000; // 排序增量 /** * 添加歌单 * @param musicSheet 歌单数据 * @returns 是否添加成功 */ static addMusicSheet(musicSheet: IDataBaseModel.IMusicSheetModel): boolean { try { const insertStmt = database.prepare(` INSERT INTO LocalMusicSheets (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // 如果没有指定排序索引,设置为当前最大值+增量 let sortIndex = musicSheet._sortIndex; if (sortIndex === undefined || sortIndex === null) { const maxSortStmt = database.prepare("SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets"); const result = maxSortStmt.get() as { maxSort: number | null }; sortIndex = (result.maxSort || 0) + this.SORT_INCREMENT; } const result = insertStmt.run( musicSheet.platform, musicSheet.id, musicSheet.title, musicSheet.artwork || null, musicSheet.description || null, musicSheet.worksNum || 0, musicSheet.playCount || 0, musicSheet.createAt || Date.now(), musicSheet.artist || null, musicSheet._raw, sortIndex, ); return result.changes > 0; } catch { return false; } } /** * 批量添加歌单 * @param musicSheets 歌单数据数组 * @returns 成功添加的数量 */ static batchAddMusicSheets(musicSheets: IDataBaseModel.IMusicSheetModel[]): number { if (!musicSheets.length) { return 0; } try { const transaction = database.transaction(() => { let successCount = 0; // 获取当前最大排序值 const maxSortStmt = database.prepare("SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets"); const result = maxSortStmt.get() as { maxSort: number | null }; let currentMaxSort = result.maxSort || 0; const insertStmt = database.prepare(` INSERT INTO LocalMusicSheets (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const sheet of musicSheets) { try { let sortIndex = sheet._sortIndex; if (sortIndex === undefined || sortIndex === null) { currentMaxSort += this.SORT_INCREMENT; sortIndex = currentMaxSort; } const insertResult = insertStmt.run( sheet.platform, sheet.id, sheet.title, sheet.artwork || null, sheet.description || null, sheet.worksNum || 0, sheet.playCount || 0, sheet.createAt || Date.now(), sheet.artist || null, sheet._raw, sortIndex, ); if (insertResult.changes > 0) { successCount++; } } catch { // 忽略单个添加失败的情况 } } return successCount; }); return transaction(); } catch { return 0; } } /** * 删除歌单 * @param platform 平台 * @param id 歌单ID * @returns 是否删除成功 */ static deleteMusicSheet(platform: string, id: string): boolean { try { const deleteStmt = database.prepare("DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const result = deleteStmt.run(platform, id); return result.changes > 0; } catch { return false; } } /** * 批量删除歌单 * @param sheets 要删除的歌单标识数组 {platform, id} * @returns 成功删除的数量 */ static batchDeleteMusicSheets(sheets: Array<{ platform: string; id: string }>): number { if (!sheets.length) { return 0; } try { const transaction = database.transaction(() => { let successCount = 0; const deleteStmt = database.prepare("DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?"); for (const sheet of sheets) { try { const result = deleteStmt.run(sheet.platform, sheet.id); if (result.changes > 0) { successCount++; } } catch { // 忽略单个删除失败的情况 } } return successCount; }); return transaction(); } catch { return 0; } } /** * 查询单个歌单 * @param platform 平台 * @param id 歌单ID * @returns 歌单数据或null */ static getMusicSheet(platform: string, id: string): IDataBaseModel.IMusicSheetModel | null { try { const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE platform = ? AND id = ? `); const result = selectStmt.get(platform, id) as any; if (!result) { return null; } return { platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, }; } catch { return null; } } /** * 查询所有歌单 * @param orderBy 排序字段,默认按_sortIndex排序 * @param order 排序方向,'ASC' 或 'DESC',默认ASC * @returns 歌单数组 */ static getAllMusicSheets(orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC"): IDataBaseModel.IMusicSheetModel[] { try { // 验证排序字段,防止SQL注入 const allowedFields = ["_sortIndex", "title", "createAt", "artist", "playCount"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets ORDER BY ${orderBy} ${order} `); const results = selectStmt.all() as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch { return []; } } /** * 按平台查询歌单 * @param platform 平台名称 * @param orderBy 排序字段 * @param order 排序方向 * @returns 歌单数组 */ static getMusicSheetsByPlatform(platform: string, orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC"): IDataBaseModel.IMusicSheetModel[] { try { const allowedFields = ["_sortIndex", "title", "createAt", "artist", "playCount"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE platform = ? ORDER BY ${orderBy} ${order} `); const results = selectStmt.all(platform) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch { return []; } } /** * 更新歌单(部分更新) * @param platform 平台 * @param id 歌单ID * @param updates 要更新的字段 * @returns 是否更新成功 */ static updateMusicSheet( platform: string, id: string, updates: Partial>, ): boolean { try { if (Object.keys(updates).length === 0) { return false; } // 构建动态更新SQL const allowedFields = ["title", "artwork", "description", "worksNum", "playCount", "createAt", "artist", "_raw", "_sortIndex"]; const updateFields: string[] = []; const values: any[] = []; for (const [key, value] of Object.entries(updates)) { if (allowedFields.includes(key) && value !== undefined) { updateFields.push(`${key} = ?`); values.push(value); } } if (updateFields.length === 0) { return false; } values.push(platform, id); const updateStmt = database.prepare(` UPDATE LocalMusicSheets SET ${updateFields.join(", ")} WHERE platform = ? AND id = ? `); const result = updateStmt.run(...values); return result.changes > 0; } catch { return false; } } /** * 重新均衡所有歌单的排序索引 * 当排序间隔过小时调用此方法 */ static rebalanceSortIndexes(): boolean { try { const transaction = database.transaction(() => { // 获取所有歌单按当前排序 const selectStmt = database.prepare("SELECT platform, id FROM LocalMusicSheets ORDER BY _sortIndex ASC"); const sheets = selectStmt.all() as Array<{ platform: string; id: string }>; const updateStmt = database.prepare("UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?"); // 重新分配排序索引,每个间隔1000 sheets.forEach((sheet, index) => { const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT); updateStmt.run(newSortIndex, sheet.platform, sheet.id); }); return true; }); return transaction(); } catch { return false; } } /** * 批量移动歌单到指定位置(支持所有拖拽和排序场景) * @param selectedSheets 要移动的歌单标识数组 * @param targetPlatform 目标歌单的平台(null表示移动到开头/末尾) * @param targetId 目标歌单的ID(null表示移动到开头/末尾) * @param position 相对于目标歌单的位置:"before" | "after",默认"after" * @returns 成功移动的数量 * * @example * // 移动到开头 * batchMoveMusicSheets(sheets, null, null, "before") * * // 移动到末尾 * batchMoveMusicSheets(sheets, null, null, "after") * * // 移动到指定歌单之前 * batchMoveMusicSheets(sheets, "platform1", "id1", "before") * * // 移动到指定歌单之后 * batchMoveMusicSheets(sheets, "platform1", "id1", "after") */ static batchMoveMusicSheets( selectedSheets: Array<{ platform: string; id: string }>, targetPlatform: string | null = null, targetId: string | null = null, position: "before" | "after" = "after", ): number { if (!selectedSheets.length) { return 0; } try { const transaction = database.transaction(() => { // 获取所有歌单的排序信息 const allSheetsStmt = database.prepare(` SELECT platform, id, _sortIndex FROM LocalMusicSheets ORDER BY _sortIndex ASC `); const allSheets = allSheetsStmt.all() as Array<{ platform: string; id: string; _sortIndex: number; }>; // 分离要移动的歌单和剩余的歌单 const selectedSheetIds = new Set(selectedSheets.map(s => `${s.platform}:${s.id}`)); const sheetsToMove = allSheets.filter(s => selectedSheetIds.has(`${s.platform}:${s.id}`)); const remainingSheets = allSheets.filter(s => !selectedSheetIds.has(`${s.platform}:${s.id}`)); if (!sheetsToMove.length) { return 0; } // 计算插入位置 let insertIndex = 0; if (targetPlatform && targetId) { const targetIndex = remainingSheets.findIndex(s => s.platform === targetPlatform && s.id === targetId, ); insertIndex = targetIndex === -1 ? 0 : (position === "after" ? targetIndex + 1 : targetIndex); } else { insertIndex = position === "before" ? 0 : remainingSheets.length; } // 构建新的排序数组并重新分配排序索引 const newOrderedSheets = [...remainingSheets]; newOrderedSheets.splice(insertIndex, 0, ...sheetsToMove); const updateStmt = database.prepare(` UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ? `); let successCount = 0; newOrderedSheets.forEach((sheet, index) => { const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT); try { const result = updateStmt.run(newSortIndex, sheet.platform, sheet.id); if (result.changes > 0 && selectedSheetIds.has(`${sheet.platform}:${sheet.id}`)) { successCount++; } } catch { // 静默处理单个歌单移动失败的情况 } }); return successCount; }); return transaction(); } catch { return 0; } } /** * 搜索歌单 * @param keyword 搜索关键词 * @param searchFields 搜索字段数组,默认搜索title和artist * @returns 匹配的歌单数组 */ static searchMusicSheets( keyword: string, searchFields: string[] = ["title", "artist"], ): IDataBaseModel.IMusicSheetModel[] { try { if (!keyword.trim()) { return this.getAllMusicSheets(); } const allowedFields = ["title", "artist", "description"]; const validFields = searchFields.filter(field => allowedFields.includes(field)); if (validFields.length === 0) { validFields.push("title"); } const whereConditions = validFields.map(field => `${field} LIKE ?`).join(" OR "); const searchPattern = `%${keyword}%`; const params = validFields.map(() => searchPattern); const searchStmt = database.prepare(` SELECT * FROM LocalMusicSheets WHERE ${whereConditions} ORDER BY _sortIndex ASC `); const results = searchStmt.all(...params) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, title: result.title, artwork: result.artwork, description: result.description, worksNum: result.worksNum, playCount: result.playCount, createAt: result.createAt, artist: result.artist, _raw: result._raw, _sortIndex: result._sortIndex, })); } catch { return []; } } /** * 获取歌单中的所有歌曲 * @param platform 歌单平台 * @param id 歌单ID * @param orderBy 排序字段,默认按_sortIndex排序 * @param order 排序方向 * @returns 歌曲数组 */ static getMusicItemsInSheet( platform: string, id: string, orderBy: string = "_sortIndex", order: "ASC" | "DESC" = "ASC", ): IDataBaseModel.IMusicItemModel[] { try { const allowedFields = ["_sortIndex", "title", "artist", "album", "_timestamp"]; if (!allowedFields.includes(orderBy)) { orderBy = "_sortIndex"; } const selectStmt = database.prepare(` SELECT * FROM LocalMusicItems WHERE _musicSheetPlatform = ? AND _musicSheetId = ? ORDER BY ${orderBy} ${order} `); const results = selectStmt.all(platform, id) as any[]; return results.map(result => ({ platform: result.platform, id: result.id, artist: result.artist, title: result.title, duration: result.duration, album: result.album, artwork: result.artwork, _timestamp: result._timestamp, _raw: result._raw, _sortIndex: result._sortIndex, _musicSheetId: result._musicSheetId, _musicSheetPlatform: result._musicSheetPlatform, })); } catch { return []; } } /** * 获取歌单中歌曲的数量 * @param platform 歌单平台 * @param id 歌单ID * @returns 歌曲数量 */ static getMusicItemCountInSheet(platform: string, id: string): number { try { const countStmt = database.prepare(` SELECT COUNT(*) as count FROM LocalMusicItems WHERE _musicSheetPlatform = ? AND _musicSheetId = ? `); const result = countStmt.get(platform, id) as { count: number }; return result.count; } catch { return 0; } } /** * 检查歌单是否存在 * @param platform 平台 * @param id 歌单ID * @returns 是否存在 */ static existsMusicSheet(platform: string, id: string): boolean { try { const countStmt = database.prepare("SELECT COUNT(*) as count FROM LocalMusicSheets WHERE platform = ? AND id = ?"); const result = countStmt.get(platform, id) as { count: number }; return result.count > 0; } catch { return false; } } } // 导出数据库实例和类 export { database, LocalMusicSheetDB }; ================================================ FILE: src/shared/database/renderer.ts ================================================ ================================================ FILE: src/shared/global-context/internal/common.ts ================================================ /** * Evt send by Renderer process */ export enum _IpcRendererEvt { GET_GLOBAL_DATA = "shared/global-data/get-global-data", } ================================================ FILE: src/shared/global-context/main.ts ================================================ import { app, ipcMain } from "electron"; import { _IpcRendererEvt } from "./internal/common"; import path from "path"; declare const WORKER_DOWNLOADER_WEBPACK_ENTRY: string; declare const LOCAL_FILE_WATCHER_WEBPACK_ENTRY: string; declare const DB_WEBPACK_ENTRY: string; export function setupGlobalContext() { ipcMain.on(_IpcRendererEvt.GET_GLOBAL_DATA, (evt) => { evt.returnValue = { appVersion: app.getVersion(), workersPath: { downloader: WORKER_DOWNLOADER_WEBPACK_ENTRY, localFileWatcher: LOCAL_FILE_WATCHER_WEBPACK_ENTRY, db: DB_WEBPACK_ENTRY, }, appPath: { downloads: app.getPath("downloads"), temp: app.getPath("temp"), userData: app.getPath("userData"), res: app.isPackaged ? path.resolve(process.resourcesPath, "res") : path.resolve(__dirname, "../../res"), }, platform: process.platform, }; }); } ================================================ FILE: src/shared/global-context/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import { IGlobalContext } from "./type"; import { _IpcRendererEvt } from "./internal/common"; let globalContext: IGlobalContext; export function getGlobalContext() { if (!globalContext) { globalContext = ipcRenderer.sendSync(_IpcRendererEvt.GET_GLOBAL_DATA); } return globalContext; } const mod = { getGlobalContext, }; getGlobalContext(); contextBridge.exposeInMainWorld("@shared/global-context", mod); ================================================ FILE: src/shared/global-context/renderer.ts ================================================ import type { IGlobalContext } from "./type"; const mod = window["@shared/global-context" as any] as any; export const getGlobalContext: () => IGlobalContext = mod.getGlobalContext; ================================================ FILE: src/shared/global-context/type.d.ts ================================================ export interface IGlobalContext { /** 版本号 */ appVersion: string; workersPath: { /** 下载器worker */ downloader: string; /** 本地文件监听器worker */ localFileWatcher: string; /** 用于备份文件的worker */ db: string; }; appPath: { userData: string; temp: string; downloads: string; res: string; }; platform: NodeJS.Platform; } ================================================ FILE: src/shared/i18n/main.ts ================================================ import { app, ipcMain } from "electron"; import path from "path"; import fs from "fs/promises"; import i18n from "i18next"; import logger from "@shared/logger/main"; const ns = "translation"; const resPath = app.isPackaged ? path.resolve(process.resourcesPath, "res") : path.resolve(__dirname, "../../res"); export const getResPath = (resourceName: string) => { return path.resolve(resPath, resourceName); }; let allLangs: string[] = []; async function readLangContent( langCode: string, enableRedirect = true, ): Promise { const langPath = path.resolve(getResPath(`./lang/${langCode}.json`)); try { const content = await fs.readFile(langPath, "utf8"); const jsonObj = JSON.parse(content); if (jsonObj["$alias"] && enableRedirect) { return readLangContent(jsonObj["$alias"], false); } return jsonObj; } catch { return null; } } interface ISetupI18nOptions { getDefaultLang?: () => string | null; onLanguageChanged?: (lang: string) => void; } export async function setupI18n(options?: ISetupI18nOptions) { const { getDefaultLang, onLanguageChanged } = options || {}; const basicDir = getResPath("./lang"); try { await i18n.init({ resources: {}, }); const dirContents = await fs.readdir(basicDir, { withFileTypes: true, }); allLangs = dirContents .filter((it) => it.isFile() && it.name.endsWith(".json")) .map((it) => it.name.slice(0, -5)); let defaultLang = getDefaultLang?.(); if (defaultLang && !allLangs.includes(defaultLang)) { defaultLang = undefined; } if (!defaultLang) { const appLocale = app.getLocale(); if (allLangs.includes(appLocale)) { defaultLang = appLocale; } else if (appLocale.includes("zh") && allLangs.includes("zh-CN")) { defaultLang = "zh-CN"; } else if (allLangs.includes("en-US")) { defaultLang = "en-US"; } else { defaultLang = allLangs[0]; } } const langContent = await readLangContent(defaultLang); if (defaultLang && langContent) { i18n.addResourceBundle(defaultLang, ns, langContent); i18n.changeLanguage(defaultLang); } ipcMain.handle("shared/i18n/setup", async () => { const currentLang = i18n.language; const langContent = await readLangContent(currentLang); if (langContent) { return { lang: currentLang, content: langContent, allLangs, }; } return null; }); ipcMain.handle("shared/i18n/changeLang", async (_, lang: string) => { if (i18n.hasResourceBundle(lang, ns)) { await i18n.changeLanguage(lang); onLanguageChanged?.(lang); return { lang, content: i18n.getResourceBundle(lang, ns), }; } else { const langContent = await readLangContent(lang); if (langContent) { i18n.addResourceBundle(lang, ns, langContent); await i18n.changeLanguage(lang); onLanguageChanged?.(lang); return { lang, content: langContent, }; } } return null; }); } catch (e){ logger.logError("I18N Setup Error", e as Error); } } export const t = i18n.t.bind(i18n); export default { setup: setupI18n, t: i18n.t, }; ================================================ FILE: src/shared/i18n/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import type { IChangeLangData, ISetupData } from "./type"; async function setupLang() { const data: ISetupData = await ipcRenderer.invoke("shared/i18n/setup"); return data; } async function changeLang(lang: string) { const data: IChangeLangData = await ipcRenderer.invoke("shared/i18n/changeLang", lang); return data; } const mod = { setupLang, changeLang, }; contextBridge.exposeInMainWorld("@shared/i18n", mod); ================================================ FILE: src/shared/i18n/renderer.ts ================================================ import i18n from "i18next"; import Store from "@/common/store"; import { initReactI18next } from "react-i18next"; import { IMod } from "./type"; i18n.use(initReactI18next); const ns = "translation"; const langListStore = new Store([]); const mod = window["@shared/i18n" as any] as unknown as IMod; export async function setupI18n() { const { allLangs = [], content, lang } = (await mod.setupLang()) || {}; langListStore.setValue(allLangs); await i18n.init({ resources: { [lang]: { [ns]: content, }, }, lng: lang, }); } export async function changeLang(lang: string): Promise { const langData = await mod.changeLang(lang); if (!langData) { return false; } if (i18n.hasResourceBundle(lang, ns)) { await i18n.changeLanguage(lang); } else { i18n.addResourceBundle(lang, ns, langData.content); await i18n.changeLanguage(lang); } return true; } export const useLangList = langListStore.useValue; export const getLangList = langListStore.getValue; export const isCN = () => i18n.language.includes("zh-CN"); export { i18n }; export default { setupI18n, changeLang, useLangList, getLangList, i18n, }; ================================================ FILE: src/shared/i18n/type.d.ts ================================================ export interface ISetupData { allLangs: string[]; lang: string; content: any } export interface IChangeLangData { lang: string; content: any; } export interface IMod { setupLang: () => Promise; changeLang: (lang: string) => Promise; } ================================================ FILE: src/shared/logger/main.ts ================================================ import log from "electron-log/main"; import { safeStringify } from "@/common/safe-serialization"; function logError(msg: string, error: Error, extra?: any) { log.error(msg, error?.name, error?.message, error?.stack, safeStringify(extra)); } function logInfo(msg: string, extra?: any) { log.info(msg, safeStringify(extra)); } let firstPerfLogTime = 0; function logPerf(msg: string) { const timestamp = Date.now(); if (!firstPerfLogTime) { firstPerfLogTime = timestamp; } log.info("[Perf Main]: " + msg + " [Offset]: " + (timestamp - firstPerfLogTime) + "ms"); } const logger = { logInfo, logError, logPerf, }; export default logger; ================================================ FILE: src/shared/logger/renderer.ts ================================================ import log from "electron-log/renderer"; import { safeStringify } from "@/common/safe-serialization"; function logError(msg: string, error: Error, extra?: any) { log.error(msg, error?.name, error?.message, error?.stack, safeStringify(extra)); } function logInfo(msg: string, extra?: any) { log.info(msg, safeStringify(extra)); } let firstPerfLogTime = 0; function logPerf(msg: string) { const timestamp = Date.now(); if (!firstPerfLogTime) { firstPerfLogTime = timestamp; } log.info("[Perf Renderer]: " + msg + " [Offset]: " + (timestamp - firstPerfLogTime) + "ms"); } const logger = { logInfo, logError, logPerf, }; export default logger; ================================================ FILE: src/shared/message-bus/main.ts ================================================ import { IAppState, ICommand } from "@shared/message-bus/type"; import { IWindowManager } from "@/types/main/window-manager"; import { BrowserWindow, ipcMain, MessageChannelMain } from "electron"; import { PlayerState, RepeatMode } from "@/common/constant"; import EventEmitter from "eventemitter3"; /** * 消息总线 * 包括应用状态、指令的同步 */ class MessageBus { private windowManager: IWindowManager; private extensionWindowIds = new Set(); private appState: IAppState = { musicItem: null, playerState: PlayerState.None, repeatMode: RepeatMode.Loop, lyricText: null, }; private ee = new EventEmitter<{ stateChanged: [IAppState, IAppState] }>(); public setup(windowManager: IWindowManager) { this.windowManager = windowManager; // 配置现有窗口 const extensionWindows = this.windowManager.getExtensionWindows(); for (const bWindow of extensionWindows) { this.createPortForExtensionWindow(bWindow); } windowManager.on("WindowCreated", (data) => { if (data.windowName !== "main") { this.createPortForExtensionWindow(data.browserWindow); } }); ipcMain.on("@shared/message-bus/sync-app-state", (_, data: IAppState) => { this.appState = { ...this.appState, ...data, }; this.ee.emit("stateChanged", this.appState, data); }); } public onAppStateChange(cb: (state: IAppState, changedAppState: IAppState) => void) { this.ee.on("stateChanged", cb); } /** * 发送指令 * @param command 指令 * @param data 数据 */ public sendCommand(command: T, data?: ICommand[T]) { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { mainWindow.webContents.send("@shared/message-bus/message", { type: "command", payload: { command, data, }, timestamp: Date.now(), }); } } public getAppState() { return this.appState; } // 创建通信端口 private createPortForExtensionWindow(bWindow: BrowserWindow) { const mainWindow = this.windowManager.mainWindow; if (!mainWindow || bWindow === mainWindow) { return; } const { port1, port2 } = new MessageChannelMain(); const extWindowId = bWindow.id; this.extensionWindowIds.add(extWindowId); // 通知主窗口更新 mainWindow.webContents.postMessage("port", { payload: extWindowId, type: "mount", timestamp: Date.now(), }, [port1]); bWindow.webContents.postMessage("port", null, [port2]); bWindow.on("close", () => { mainWindow.webContents.postMessage("port", { payload: extWindowId, type: "unmount", timestamp: Date.now(), }); this.extensionWindowIds.delete(extWindowId); }); } } const messageBus = new MessageBus(); export default messageBus; ================================================ FILE: src/shared/message-bus/preload/extension.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import { IAppState, ICommand, IPortMessage } from "@shared/message-bus/type"; import EventEmitter from "eventemitter3"; let extPort: MessagePort = null; let appState: IAppState = {}; const ee = new EventEmitter<{ stateChanged: [IAppState, IAppState]; }>(); // 初始化 let connected = false; let pingTimer: NodeJS.Timeout | null = null; // 缓存未建立连接时的消息 const cachedMessages: IPortMessage[] = []; ipcRenderer.on("port", (e) => { extPort = e.ports[0]; pingTimer = setInterval(() => { console.log("ping"); // 向主进程发送 ping extPort.postMessage({ type: "ping", timestamp: Date.now(), }); }, 300); extPort.onmessage = (evt) => { const data = evt.data; if (data.type === "syncAppState") { appState = { ...appState, ...(data.payload || {}), }; ee.emit("stateChanged", appState, data.payload || {}); } else if (data.type === "ping") { connected = true; clearInterval(pingTimer); pingTimer = null; if (cachedMessages.length) { cachedMessages.forEach((message) => { extPort.postMessage(message); }); cachedMessages.length = 0; } } }; }); function sendCommand(command: T, data?: ICommand[T]) { const message: IPortMessage = { type: "command", payload: { command, data, }, timestamp: Date.now(), }; if (!extPort || !connected) { cachedMessages.push(message); return; } extPort.postMessage(message); } function subscribeAppState(keys: (keyof IAppState)[]) { const message: IPortMessage = { type: "subscribeAppState", payload: keys, timestamp: Date.now(), }; if (!extPort || !connected) { cachedMessages.push(message); return; } extPort.postMessage(message); } function getAppState() { return appState; } function onStateChange( cb: (appState: IAppState, changedAppState: IAppState) => void, ) { ee.on("stateChanged", cb); } function offStateChange( cb: (appState: IAppState, changedAppState: IAppState) => void, ) { ee.off("stateChanged", cb); } const mod = { getAppState, subscribeAppState, sendCommand, onStateChange, offStateChange, }; contextBridge.exposeInMainWorld("@shared/message-bus/extension", mod); ================================================ FILE: src/shared/message-bus/preload/main.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import EventEmitter from "eventemitter3"; import { IAppState, ICommand, IPortMessage } from "@shared/message-bus/type"; import { getGlobalContext } from "@shared/global-context/preload"; const extPorts = new Map(); const subscribedAppStates = new Map>(); const mainProcessSubscribedKeys: Array = [ "lyricText", "playerState", "repeatMode", "musicItem", ]; if (getGlobalContext().platform === "darwin") { mainProcessSubscribedKeys.push("lyricText"); } subscribedAppStates.set("main", mainProcessSubscribedKeys); const ee = new EventEmitter(); //@ts-ignore window.__extPorts = extPorts; // 主窗口的端口信息 (和拓展端口通信) ipcRenderer.on("port", (e, message) => { // 接收到端口,使其全局可用 if (message.type === "mount") { const expPort = e.ports[0]; extPorts.set(message.payload, expPort); expPort.onmessage = (evt) => { const data = evt.data; handleMessage(data, message.payload); }; } else if (message.type === "unmount") { const closeId = message.payload; const expPort = extPorts.get(closeId); if (expPort) { expPort.close(); extPorts.delete(closeId); subscribedAppStates.delete(closeId); } } else { // 其他类型作为主进程发来的普通消息处理 handleMessage(message, null); } }); ipcRenderer.on("@shared/message-bus/message", (_evt, message) => { handleMessage(message, null); }); function handleMessage(data: IPortMessage, from: number | null) { const { type, payload, timestamp } = data; if (type === "mount" || type === "unmount") { // those are not real message return; } if (type === "ping") { // 渲染进程发来的建连消息 const expPort = extPorts.get(from); // 返回一个相同的ping if (expPort) { expPort.postMessage({ type: "ping", timestamp: Date.now(), }); } } else if (type === "subscribeAppState" && from !== null) { // @ts-ignore subscribedAppStates.set(from, payload); } else if (type === "command") { ee.emit("command", payload, from); } } function onCommand( command: K, cb: (data: ICommand[K], from: "main" | number) => void, ) { ee.on("command", (payload, from) => { if (payload.command === command) { cb?.(payload.data, from); } }); } function sendCommand(command: K, data: ICommand[K]) { ee.emit( "command", { command: command, data: data, timestamp: Date.now(), }, -1, ); } function syncAppState(appState: IAppState, to?: "main" | number) { if (to !== undefined) { syncAppStateTo(appState, to); return; } // 同步全部 syncAppStateTo(appState, "main"); for (const key of extPorts.keys()) { syncAppStateTo(appState, key); } } function syncAppStateTo(appState: IAppState, processId: "main" | number) { const data: IAppState = {}; if (processId === "main") { const mainSubscribedKeys = subscribedAppStates.get(processId); let cnt = 0; mainSubscribedKeys.forEach((key) => { if (appState[key] !== undefined) { // @ts-ignore data[key] = appState[key]; ++cnt; } }); if (cnt) { ipcRenderer.send("@shared/message-bus/sync-app-state", data); } return; } const expPort = extPorts.get(processId); const subscribedKeys = subscribedAppStates.get(processId); if (subscribedKeys && expPort) { const data: IAppState = {}; let cnt = 0; subscribedKeys.forEach((key) => { if (appState[key] !== undefined) { // @ts-ignore data[key] = appState[key]; ++cnt; } }); if (cnt) { expPort.postMessage({ type: "syncAppState", payload: data, timestamp: Date.now(), }); } } } const mod = { syncAppState, onCommand, sendCommand, }; contextBridge.exposeInMainWorld("@shared/message-bus/main", mod); ================================================ FILE: src/shared/message-bus/renderer/extension.ts ================================================ import { IAppState, ICommand } from "@shared/message-bus/type"; import { useEffect, useState } from "react"; interface IMod { sendCommand: ( command: K, data?: ICommand[K] ) => void; getAppState: () => IAppState; subscribeAppState: (keys: (keyof IAppState)[]) => void; onStateChange: ( cb: (appState: IAppState, changedAppState: IAppState) => void ) => void; offStateChange: ( cb: (appState: IAppState, changedAppState: IAppState) => void ) => void; } const mod = window["@shared/message-bus/extension" as any] as unknown as IMod; export function useAppState() { const [appState, setAppState] = useState(mod.getAppState); useEffect(() => { mod.onStateChange(setAppState); setAppState(mod.getAppState); return () => { mod.offStateChange(setAppState); }; }, []); return appState; } export function useAppStatePartial(key: K) { const [appState, setAppState] = useState( mod.getAppState()?.[key], ); useEffect(() => { const cb = (appState: IAppState, changedAppState: IAppState) => { if (key in changedAppState) { setAppState(mod.getAppState()[key]); } }; mod.onStateChange(cb); setAppState(mod.getAppState()?.[key]); return () => { mod.offStateChange(cb); }; }, []); return appState; } const messageBus = { sendCommand: mod.sendCommand, subscribeAppState: mod.subscribeAppState, onStateChange: mod.onStateChange, offStateChange: mod.offStateChange, }; export default messageBus; ================================================ FILE: src/shared/message-bus/renderer/main.ts ================================================ import { IAppState, ICommand } from "@shared/message-bus/type"; interface IMod { syncAppState: (appState: IAppState, to?: "main" | number) => void; onCommand: (command: K, cb: (data: ICommand[K], from: "main" | number) => void) => void; sendCommand: (command: K, data?: ICommand[K]) => void; } const messageBus = window["@shared/message-bus/main" as any] as unknown as IMod; export default messageBus; ================================================ FILE: src/shared/message-bus/type.d.ts ================================================ import { PlayerState, RepeatMode } from "@/common/constant"; import type { IParsedLrcItem } from "@renderer/utils/lyric-parser"; export interface IAppState { musicItem?: IMusic.IMusicItem | null; playerState?: PlayerState; repeatMode?: RepeatMode; lyricText?: string | null; parsedLrc?: IParsedLrcItem | null; fullLyric?: IParsedLrcItem[] | null; progress?: number; duration?: number; } export interface ICommand { /** 切换播放器状态 */ TogglePlayerState: void; /** 切换上一首歌 */ SkipToPrevious: void; /** 切换下一首歌 */ SkipToNext: void; /** 设置循环模式 */ SetRepeatMode: RepeatMode; /** 播放音乐 */ PlayMusic: IMusic.IMusicItem; /** 跳转路由 */ Navigate: string; /** 声音调大 */ VolumeUp: number; /** 声音调小 */ VolumeDown: number; /** 切换喜爱状态 */ ToggleFavorite: IMusic.IMusicItem | null; /** 切换桌面歌词状态 */ ToggleDesktopLyric: void; /** 同步音乐状态 */ SyncAppState: void; /** 打开音乐详情页面 */ OpenMusicDetailPage: void; /** 切换主窗口显示 */ ToggleMainWindowVisible: void; } // 内部使用的消息 // 其他窗口向主窗口发送的消息 export interface IPortMessagePayload< CommandKey extends keyof ICommand = keyof ICommand, StateKey extends keyof IAppState = keyof IAppState > { mount: number; unmount: number; command: { command: CommandKey; data: ICommand[CommandKey]; }; subscribeAppState: StateKey[]; ping: undefined; } export interface IPortMessage< T extends keyof IPortMessagePayload = keyof IPortMessagePayload > { type: T; payload: IPortMessagePayload[T]; timestamp: number; } ================================================ FILE: src/shared/plugin-manager/main/index.ts ================================================ import { app, ipcMain } from "electron"; import fs from "fs/promises"; import path from "path"; import { Plugin } from "./plugin"; import { rimraf } from "rimraf"; import _axios from "axios"; import https from "https"; import voidCallback from "@/common/void-callback"; import { localPluginHash, localPluginName } from "@/common/constant"; import localPlugin from "./internal-plugins/local-plugin"; import { addRandomHash } from "@/common/normalize-util"; import { IWindowManager } from "@/types/main/window-manager"; import AppConfig from "@shared/app-config/main"; import { compare } from "compare-versions"; import { nanoid } from "nanoid"; import logger from "@shared/logger/main"; const axios = _axios.create({ httpsAgent: new https.Agent({ rejectUnauthorized: false, }), }); interface ICallPluginMethodParams< T extends keyof IPlugin.IPluginInstanceMethods, > { hash: string; platform: string; method: T; args: Parameters; } class PluginManager { private clonedPlugins: IPlugin.IPluginDelegate[] = []; private inited = false; private _plugins: Plugin[] = []; public get plugins() { return this._plugins; } public set plugins(newPlugins: Plugin[]) { this._plugins = newPlugins; this.clonedPlugins = newPlugins.map((p) => { const sPlugin: IPlugin.IPluginDelegate = {} as any; sPlugin.supportedMethod = []; for (const k in p.instance) { // @ts-ignore if (typeof p.instance[k] === "function") { sPlugin.supportedMethod.push(k); } else { // @ts-ignore sPlugin[k] = p.instance[k]; } } sPlugin.hash = p.hash; sPlugin.path = p.path; return JSON.parse(JSON.stringify(sPlugin)); }); } private windowManager: IWindowManager; // 插件存储路径 private _pluginBasePath: string; private get pluginBasePath() { if (this._pluginBasePath) { return this._pluginBasePath; } this._pluginBasePath = path.resolve( app.getPath("userData"), "./musicfree-plugins", ); return this._pluginBasePath; } public async setup(windowManager: IWindowManager) { this.windowManager = windowManager; // 1. setup events ipcMain.handle("@shared/plugin-manager/call-plugin-method", (_evt, data) => { return this.callPluginMethod(data); }); ipcMain.handle("@shared/plugin-manager/get-all-plugins", () => this.clonedPlugins); ipcMain.handle("@shared/plugin-manager/load-all-plugins", async () => { if (!this.inited) { await this.loadAllPlugins(); } else { this.syncPlugins(); } return this.clonedPlugins; }); ipcMain.handle("@shared/plugin-manager/uninstall-plugin", async (_, hash) => { await this.uninstallPlugin(hash); this.syncPlugins(); }); ipcMain.on("@shared/plugin-manager/update-all-plugins", this.updateAllPlugins); ipcMain.handle("@shared/plugin-manager/install-plugin-remote", async (_, urlLike) => { return await this.installPluginFromRemoteUrl(urlLike); }); ipcMain.handle("@shared/plugin-manager/install-plugin-local", async (_, urlLike) => { return await this.installPluginFromLocalFile(urlLike); }); // 2. check if folder exists let folderExists = true; try { const res = await fs.stat(this.pluginBasePath); if (!res.isDirectory()) { await rimraf(this.pluginBasePath); folderExists = false; } } catch { folderExists = false; } if (!folderExists) { await fs.mkdir(this.pluginBasePath, { recursive: true, }).catch(voidCallback); } // 3. load all plugins await this.loadAllPlugins(); this.inited = true; } // 调用某个插件的方法 private callPluginMethod({ hash, platform, method, args, }: ICallPluginMethodParams, ) { let plugin: Plugin; if (hash === localPluginHash || platform === localPluginName) { plugin = localPlugin; } else if (hash) { plugin = this.plugins.find((item) => item.hash === hash); } else if (platform) { plugin = this.plugins.find((item) => item.name === platform); } if (!plugin) { return null; } return plugin.methods[method]?.apply?.({ plugin }, args); } private syncPlugins() { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { mainWindow.webContents.send("@/shared/plugin-manager/sync-plugins", this.clonedPlugins); } } /********************** 安装插件 *******************/ private async installPluginFromRawCodeImpl(funcCode: string) { const plugins = this.plugins; const plugin = new Plugin(funcCode, ""); const pluginIndex = plugins.findIndex((p) => p.hash === plugin.hash); if (pluginIndex !== -1) { // 静默忽略 return; } const oldVersionPlugin = plugins.find((p) => p.name === plugin.name); if ( oldVersionPlugin && !AppConfig.getConfig("plugin.notCheckPluginVersion") ) { if ( compare( oldVersionPlugin.instance.version ?? "", plugin.instance.version ?? "", ">", ) ) { throw new Error("已安装更新版本的插件"); } } if (plugin.hash !== "") { const fn = nanoid(); const _pluginPath = path.resolve(this.pluginBasePath, `${fn}.js`); await fs.writeFile(_pluginPath, funcCode, "utf8"); plugin.path = _pluginPath; let newPlugins = plugins.concat(plugin); if (oldVersionPlugin) { newPlugins = newPlugins.filter((_) => _.hash !== oldVersionPlugin.hash); try { await rimraf(oldVersionPlugin.path); } catch { // pass } } this.plugins = newPlugins; return; } throw new Error("插件无法解析!"); } private async installPluginFromUrlImpl(urlLike: string) { const funcCode = (await axios.get(urlLike)).data; if (funcCode) { await this.installPluginFromRawCodeImpl(funcCode); } } // 加载所有插件 public async loadAllPlugins() { const rawPluginNames = await fs.readdir(this.pluginBasePath); const pluginHashSet = new Set(); const plugins: Plugin[] = []; for (let i = 0; i < rawPluginNames.length; ++i) { try { const pluginPath = path.resolve(this.pluginBasePath, rawPluginNames[i]); const fileStat = await fs.stat(pluginPath); if (fileStat.isFile() && path.extname(pluginPath) === ".js") { const funcCode = await fs.readFile(pluginPath, "utf-8"); const plugin = new Plugin(funcCode, pluginPath); if (pluginHashSet.has(plugin.hash)) { continue; } if (plugin.hash !== "") { pluginHashSet.add(plugin.hash); plugins.push(plugin); } } } catch (e) { logger.logError("插件加载失败", e); } } this.plugins = plugins; this.syncPlugins(); } // 从本地文件安装插件 public async installPluginFromLocalFile(urlLike: string) { try { const url = urlLike.trim(); if (url.endsWith(".js")) { const rawCode = await fs.readFile(url, "utf8"); await this.installPluginFromRawCodeImpl(rawCode); } else if (url.endsWith(".json")) { const jsonFile = JSON.parse(await fs.readFile(url, "utf8")); for (const cfg of jsonFile?.plugins ?? []) { await this.installPluginFromUrlImpl(addRandomHash(cfg.url)); } } } finally { this.syncPlugins(); } } // 从远程url安装插件 public async installPluginFromRemoteUrl(urlLike: string) { try { const url = urlLike.trim(); if (url.endsWith(".js")) { await this.installPluginFromUrlImpl(addRandomHash(url)); } else if (url.endsWith(".json")) { const jsonFile = (await axios.get(addRandomHash(url))).data; for (const cfg of jsonFile?.plugins ?? []) { await this.installPluginFromUrlImpl(addRandomHash(cfg.url)); } } } finally { this.syncPlugins(); } } // 更新所有插件 public async updateAllPlugins() { return Promise.allSettled( this.plugins.map((plg) => plg.instance.srcUrl ? this.installPluginFromRemoteUrl(plg.instance.srcUrl) : null, ), ); } // 卸载插件 public async uninstallPlugin(hash: string) { const targetIndex = this.plugins.findIndex((_) => _.hash === hash); if (targetIndex !== -1) { try { await rimraf(this.plugins[targetIndex].path); this.plugins = this.plugins.filter((_) => _.hash !== hash); } catch { // pass } } } } export default new PluginManager(); ================================================ FILE: src/shared/plugin-manager/main/internal-plugins/local-plugin.ts ================================================ import { localPluginHash, localPluginName } from "@/common/constant"; import { Plugin } from "../plugin"; import { addFileScheme, parseLocalMusicItem, parseLocalMusicItemFolder } from "@/common/file-util"; function localPluginDefine(): IPlugin.IPluginInstance { return { platform: localPluginName, _path: "", async getMediaSource(musicItem) { return { url: addFileScheme(musicItem.url), }; }, async getLyric(musicItem) { return { rawLrc: musicItem.rawLrc, }; }, async importMusicItem(filePath) { return parseLocalMusicItem(filePath); }, async importMusicSheet(folderPath) { return parseLocalMusicItemFolder(folderPath); }, }; } const localPlugin = new Plugin(localPluginDefine, ""); localPlugin.hash = localPluginHash; export default localPlugin; ================================================ FILE: src/shared/plugin-manager/main/plugin-methods.ts ================================================ import { getInternalData, resetMediaItem } from "@/common/media-util"; import type { Plugin } from "./plugin"; import { localFilePathSymbol } from "@/common/constant"; import fs from "fs/promises"; import { delay } from "@/common/time-util"; import axios from "axios"; import { addFileScheme, safeStat } from "@/common/file-util"; import path from "path"; export default class PluginMethods implements IPlugin.IPluginInstanceMethods { private plugin; constructor(plugin: Plugin) { this.plugin = plugin; } /** 搜索 */ async search( query: string, page: number, type: T, ): Promise> { if (!this.plugin.instance.search) { return { isEnd: true, data: [], }; } const result = await this.plugin.instance.search(query, page, type); console.log(result, this.plugin.instance.search, query, page, type); if (Array.isArray(result.data)) { result.data.forEach((_) => { resetMediaItem(_, this.plugin.name); }); return { isEnd: result.isEnd ?? true, data: result.data, }; } return { isEnd: true, data: [], }; } /** 获取真实源 */ async getMediaSource( musicItem: IMedia.IMediaBase, quality: IMusic.IQualityKey = "standard", retryCount = 1, notUpdateCache = false, ): Promise { // TODO 2. url 缓存策略,先略过 // 3 插件解析 if (!this.plugin.instance.getMediaSource) { return { url: musicItem?.qualities?.[quality]?.url ?? musicItem.url }; } try { const { url, headers } = (await this.plugin.instance.getMediaSource( musicItem, quality, )) ?? { url: musicItem?.qualities?.[quality]?.url }; if (!url) { throw new Error("NOT RETRY"); } const result = { url, headers, userAgent: headers?.["user-agent"], } as IPlugin.IMediaSourceResult; // if (pluginCacheControl !== CacheControl.NoStore && !notUpdateCache) { // Cache.update(musicItem, [ // ["headers", result.headers], // ["userAgent", result.userAgent], // [`qualities.${quality}.url`, url], // ]); // } return result; } catch (e: any) { console.log(e); if (retryCount > 0 && e?.message !== "NOT RETRY") { await delay(150); return this.plugin.methods.getMediaSource( musicItem, quality, --retryCount, ); } // devLog('error', '获取真实源失败', e, e?.message); return null; } } /** 获取音乐详情 */ async getMusicInfo( musicItem: IMedia.IMediaBase, ): Promise | null> { if (!this.plugin.instance.getMusicInfo) { return null; } try { return ( this.plugin.instance.getMusicInfo( resetMediaItem(musicItem, undefined, true), ) ?? null ); } catch (e: any) { // devLog('error', '获取音乐详情失败', e, e?.message); return null; } } /** 获取歌词 */ async getLyric( musicItem: IMusic.IMusicItem, ): Promise { let rawLrc = musicItem.rawLrc; let lrcUrl = musicItem.lrc; let translation: string; // 如果存在文本 if (rawLrc) { return { rawLrc, lrc: lrcUrl, }; } // 2. 读取路径下的同名lrc文件 const localPath = getInternalData(musicItem, "downloadData") ?.path || musicItem.$$localPath; if (localPath) { const fileName = path.parse(localPath).name; const lrcPathWithoutExt = path.resolve(localPath, `../${fileName}`); const lrcTranslationPathWithoutExt = path.resolve( localPath, `../${fileName}-tr`, ); const exts = [".lrc", ".LRC", ".txt"]; for (const ext of exts) { const lrcFilePath = lrcPathWithoutExt + ext; if ((await safeStat(lrcFilePath))?.isFile()) { rawLrc = await fs.readFile(lrcFilePath, "utf8"); if ((await safeStat(lrcTranslationPathWithoutExt + ext))?.isFile()) { translation = await fs.readFile( lrcTranslationPathWithoutExt + ext, "utf8", ); } if (rawLrc) { return { rawLrc, translation, lrc: lrcUrl, }; } } } } // // 2.本地缓存 // const localLrc = // meta?.[internalSerializeKey]?.local?.localLrc || // cache?.[internalSerializeKey]?.local?.localLrc; // if (localLrc && (await exists(localLrc))) { // rawLrc = await readFile(localLrc, 'utf8'); // return { // rawLrc, // lrc: lrcUrl, // }; // } // 3.优先使用url try { const lrcSource = await this.plugin.instance?.getLyric?.( resetMediaItem(musicItem, undefined, true), ); rawLrc = lrcSource?.rawLrc; lrcUrl = lrcSource?.lrc || lrcUrl; translation = lrcSource?.translation; if (rawLrc || translation) { if (!rawLrc) { rawLrc = translation; translation = undefined; } return { rawLrc, translation, }; } } catch (e: any) { // trace('插件获取歌词失败', e?.message, 'error'); // devLog('error', '插件获取歌词失败', e, e?.message); } if (lrcUrl) { try { rawLrc = (await axios.get(lrcUrl, { timeout: 5000 })).data; return { rawLrc, lrc: lrcUrl, translation, }; } catch { lrcUrl = undefined; } } // // 6. 如果是本地文件 // const isDownloaded = LocalMusicSheet.isLocalMusic(musicItem); // if (musicItem.platform !== localPluginPlatform && isDownloaded) { // const res = await localFilePlugin.instance!.getLyric!(isDownloaded); // if (res) { // return res; // } // } // devLog('warn', '无歌词'); return null; } /** 获取专辑信息 */ async getAlbumInfo( albumItem: IAlbum.IAlbumItem, page = 1, ): Promise { if (!this.plugin.instance.getAlbumInfo) { return { albumItem, musicList: (albumItem?.musicList ?? []).map((it) => resetMediaItem(it, this.plugin.name), ), isEnd: true, }; } try { const result = await this.plugin.instance.getAlbumInfo( resetMediaItem(albumItem, undefined, true), page, ); if (!result) { throw new Error(); } result?.musicList?.forEach((_) => { resetMediaItem(_, this.plugin.name); _.album = albumItem.title; }); if (page <= 1) { // 合并信息 return { albumItem: { ...albumItem, ...(result?.albumItem ?? {}) }, isEnd: result.isEnd === false ? false : true, musicList: result.musicList, }; } else { return { isEnd: result.isEnd === false ? false : true, musicList: result.musicList, }; } } catch (e: any) { // trace('获取专辑信息失败', e?.message); // devLog('error', '获取专辑信息失败', e, e?.message); return null; } } /** 获取歌单信息 */ async getMusicSheetInfo( sheetItem: IMusic.IMusicSheetItem, page = 1, ): Promise { if (!this.plugin.instance.getMusicSheetInfo) { return { sheetItem, musicList: sheetItem?.musicList ?? [], isEnd: true, }; } try { const result = await this.plugin.instance?.getMusicSheetInfo?.( resetMediaItem(sheetItem, undefined, true), page, ); if (!result) { throw new Error(); } result?.musicList?.forEach((_) => { resetMediaItem(_, this.plugin.name); }); if (page <= 1) { // 合并信息 return { sheetItem: { ...sheetItem, ...(result?.sheetItem ?? {}) }, isEnd: result.isEnd === false ? false : true, musicList: result.musicList, }; } else { return { isEnd: result.isEnd === false ? false : true, musicList: result.musicList, }; } } catch (e: any) { // trace('获取歌单信息失败', e, e?.message); // devLog('error', '获取歌单信息失败', e, e?.message); return null; } } /** 查询作者信息 */ async getArtistWorks( artistItem: IArtist.IArtistItem, page: number, type: T, ): Promise> { if (!this.plugin.instance.getArtistWorks) { return { isEnd: true, data: [], }; } try { const result = await this.plugin.instance.getArtistWorks( artistItem, page, type, ); if (!result.data) { return { isEnd: true, data: [], }; } result.data?.forEach((_) => resetMediaItem(_, this.plugin.name)); return { isEnd: result.isEnd ?? true, data: result.data, }; } catch (e: any) { // trace('查询作者信息失败', e?.message); // devLog('error', '查询作者信息失败', e, e?.message); console.log(e); throw e; } } /** 导入歌单 */ async importMusicSheet(urlLike: string): Promise { try { const result = (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; result.forEach((_) => resetMediaItem(_, this.plugin.name)); return result; } catch (e: any) { console.log(e); // devLog('error', '导入歌单失败', e, e?.message); return []; } } /** 导入单曲 */ async importMusicItem(urlLike: string): Promise { try { const result = await this.plugin.instance?.importMusicItem?.(urlLike); if (!result) { throw new Error(); } resetMediaItem(result, this.plugin.name); return result; } catch (e: any) { // devLog('error', '导入单曲失败', e, e?.message); return null; } } /** 获取榜单 */ async getTopLists(): Promise { try { const result = await this.plugin.instance?.getTopLists?.(); if (!result) { throw new Error(); } return result; } catch (e: any) { // devLog('error', '获取榜单失败', e, e?.message); return []; } } /** 获取榜单详情 */ async getTopListDetail( topListItem: IMusic.IMusicSheetItem, page: number, ): Promise { try { const result = await this.plugin.instance?.getTopListDetail?.( topListItem, page, ); if (!result) { throw new Error(); } if (result.musicList) { result.musicList.forEach((_) => resetMediaItem(_, this.plugin.name)); } if (result.isEnd !== false) { result.isEnd = true; } return result; } catch (e: any) { // devLog('error', '获取榜单详情失败', e, e?.message); return { isEnd: true, topListItem, musicList: [], }; } } /** 获取推荐歌单的tag */ async getRecommendSheetTags(): Promise { try { const result = await this.plugin.instance?.getRecommendSheetTags?.(); if (!result) { throw new Error(); } return result; } catch (e: any) { // devLog('error', '获取推荐歌单失败', e, e?.message); return { data: [], }; } } /** 获取某个tag的推荐歌单 */ async getRecommendSheetsByTag( tagItem: IMedia.IUnique, page?: number, ): Promise> { try { const result = await this.plugin.instance?.getRecommendSheetsByTag?.( tagItem, page ?? 1, ); if (!result) { throw new Error(); } if (result.isEnd !== false) { result.isEnd = true; } if (!result.data) { result.data = []; } result.data.forEach((item) => resetMediaItem(item, this.plugin.name)); return result; } catch (e: any) { // devLog('error', '获取推荐歌单详情失败', e, e?.message); return { isEnd: true, data: [], }; } } async getMusicComments(musicItem: IMusic.IMusicItem, page = 1): Promise { try { const result = await this.plugin.instance?.getMusicComments?.( musicItem, page, ); if (!result) { throw new Error(); } return result; } catch (e: any) { return { isEnd: true, data: [], }; } } } ================================================ FILE: src/shared/plugin-manager/main/plugin.ts ================================================ import CryptoJs from "crypto-js"; import dayjs from "dayjs"; import axios from "axios"; import bigInt from "big-integer"; import qs from "qs"; import * as cheerio from "cheerio"; import he from "he"; import PluginMethods from "./plugin-methods"; import reactNativeCookies from "./polyfill/react-native-cookies"; import { app } from "electron"; import * as webdav from "webdav"; import AppConfig from "@shared/app-config/main"; import pluginStorage from "@shared/plugin-manager/main/polyfill/storage"; axios.defaults.timeout = 15000; const sha256 = CryptoJs.SHA256; export enum PluginStateCode { /** 版本不匹配 */ VersionNotMatch = "VERSION NOT MATCH", /** 无法解析 */ CannotParse = "CANNOT PARSE", } const packages: Record = { cheerio, "crypto-js": CryptoJs, axios, dayjs, "big-integer": bigInt, qs, he, "@react-native-cookies/cookies": reactNativeCookies, webdav, "musicfree/storage": pluginStorage, }; const _require = (packageName: string) => { const pkg = packages[packageName]; if (pkg) { pkg.default = pkg; return pkg; } return null; }; // const _consoleBind = function ( // method: 'logger' | 'error' | 'info' | 'warn', // ...args: any // ) { // const fn = console[method]; // if (fn) { // fn(...args); // devLog(method, ...args); // } // }; // const _console = { // logger: _consoleBind.bind(null, 'logger'), // warn: _consoleBind.bind(null, 'warn'), // info: _consoleBind.bind(null, 'info'), // error: _consoleBind.bind(null, 'error'), // }; //#region 插件类 export class Plugin { /** 插件名 */ public name: string; /** 插件的hash,作为唯一id */ public hash: string; /** 插件状态信息 */ public stateCode?: PluginStateCode; /** 插件的实例 */ public instance: IPlugin.IPluginInstance; /** 插件路径 */ public path: string; /** 插件方法 */ public methods: PluginMethods; constructor( funcCode: string | (() => IPlugin.IPluginInstance), pluginPath: string, ) { let _instance: IPlugin.IPluginInstance; const _module: any = { exports: {}, loaded: false }; let loadResolveCallback: () => void = null; const ensurePluginInitialized = new Promise((resolve) => { loadResolveCallback = resolve; }); try { if (typeof funcCode === "string") { // 插件的环境变量 const env = { getUserVariables: () => { return ( AppConfig.getConfig("private.pluginMeta")?.[this.name] ?.userVariables ?? {} ); }, os: process.platform, appVersion: app.getVersion(), lang: AppConfig.getConfig("normal.language"), }; const _process = { platform: process.platform, version: app.getVersion(), env, ensurePluginInitialized, }; _instance = Function(` 'use strict'; return function(require, __musicfree_require, module, exports, console, env, process) { ${funcCode} } `)()( _require, _require, _module, _module.exports, console, env, _process, ); if (_module.exports.default) { _instance = _module.exports.default as IPlugin.IPluginInstance; } else { _instance = _module.exports as IPlugin.IPluginInstance; } loadResolveCallback?.(); } else { _instance = funcCode(); } // 插件初始化后的一些操作 if (Array.isArray(_instance.userVariables)) { _instance.userVariables = _instance.userVariables.filter( (it) => it?.key, ); } this.checkValid(_instance); } catch (e: any) { this.stateCode = PluginStateCode.CannotParse; if (e?.stateCode) { this.stateCode = e.stateCode; } _instance = e?.instance ?? { _path: "", platform: "", appVersion: "", async getMediaSource() { return null; }, async search() { return {}; }, async getAlbumInfo() { return null; }, }; } this.instance = _instance; this.path = pluginPath; this.name = _instance.platform; if (this.instance.platform === "" || this.instance.platform === undefined) { this.hash = ""; } else { if (typeof funcCode === "string") { this.hash = sha256(funcCode).toString(); } else { this.hash = sha256(funcCode.toString()).toString(); } } _module.loaded = true; // 放在最后 this.methods = new PluginMethods(this); } private checkValid(_instance: IPlugin.IPluginInstance) { /** 版本号校验 */ // if ( // _instance.appVersion && // !satisfies(DeviceInfo.getVersion(), _instance.appVersion) // ) { // throw { // instance: _instance, // stateCode: PluginStateCode.VersionNotMatch, // }; // } return true; } } //#endregion ================================================ FILE: src/shared/plugin-manager/main/polyfill/react-native-cookies.ts ================================================ import { session } from "electron"; interface Cookie { name: string; value: string; path?: string; domain?: string; version?: string; expires?: string; secure?: boolean; httpOnly?: boolean; } export interface Cookies { [key: string]: Cookie; } async function set( url: string, cookie: Cookie, ): Promise { try { await session.defaultSession.cookies.set({ url, ...cookie, }); return true; } catch { return false; } } async function get(url: string): Promise { try { const result = await session.defaultSession.cookies.get({ url, }); const resultMap: Cookies = {}; for (const r of result) { resultMap[r.name] = r; } return resultMap; } catch { return null; } } async function flush(): Promise { return session.defaultSession.cookies.flushStore(); } export default { set, get, flush, }; ================================================ FILE: src/shared/plugin-manager/main/polyfill/storage.ts ================================================ import { app } from "electron"; import path from "path"; import fs from "fs/promises"; import { rimraf } from "rimraf"; const MAX_STORAGE_SIZE = 1024 * 1024 * 10; let storage: Record = {}; let loaded = false; async function loadStorage() { if (loaded) { return storage; } try { const storagePath = path.resolve(app.getPath("appData"), "./musicfree-plugin-storage/chunk.json"); const storageString = await fs.readFile(storagePath, "utf-8"); storage = JSON.parse(storageString); } catch { // pass } loaded = true; } async function saveStorage(newStorage: Record) { const storageString = JSON.stringify(newStorage, undefined, 0); if (Buffer.byteLength(storageString, "utf-8") > MAX_STORAGE_SIZE) { throw new Error("Storage size exceeds limit"); } const storagePath = path.resolve(app.getPath("appData"), "./musicfree-plugin-storage/chunk.json"); let fileExist = true; try { const stat = await fs.stat(storagePath); if (!stat.isFile()) { fileExist = false; await rimraf(storagePath); } } catch { fileExist = false; } if (!fileExist) { await fs.mkdir(path.resolve(storagePath, ".."), { recursive: true, }); } storage = newStorage; await fs.writeFile(storagePath, storageString, "utf-8"); } async function setItem(key: string, value: unknown) { if (!loaded) { await loadStorage(); } const newStorage = { ...storage, [key]: typeof value === "string" ? value : value?.toString?.(), }; await saveStorage(newStorage); } async function getItem(key: string) { if (!loaded) { await loadStorage(); } return storage[key] ?? null; } async function removeItem(key: string) { if (!loaded) { await loadStorage(); } const newStorage = { ...storage, }; delete newStorage[key]; await saveStorage(newStorage); } export default { setItem, getItem, removeItem, }; ================================================ FILE: src/shared/plugin-manager/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; ipcRenderer.on("@/shared/plugin-manager/sync-plugins", (_evt, newPlugins) => { pluginUpdateCallback?.(newPlugins); }); let pluginUpdateCallback: (plugins: IPlugin.IPluginDelegate[]) => void; function onPluginUpdated(callback: (plugins: IPlugin.IPluginDelegate[]) => void) { pluginUpdateCallback = callback; } interface IPluginDelegateLike { platform?: string; hash?: string; } async function callPluginMethod< T extends keyof IPlugin.IPluginInstanceMethods, >( pluginDelegate: IPluginDelegateLike, method: T, ...args: Parameters ) { return (await ipcRenderer.invoke("@shared/plugin-manager/call-plugin-method", { hash: pluginDelegate.hash, platform: pluginDelegate.platform, method, args, })) as ReturnType; } async function reloadPlugins() { const result = await ipcRenderer.invoke("@shared/plugin-manager/load-all-plugins"); pluginUpdateCallback?.(result); } async function uninstallPlugin(hash: string) { await ipcRenderer.invoke("@shared/plugin-manager/uninstall-plugin", hash); } async function updateAllPlugins() { ipcRenderer.emit("@shared/plugin-manager/update-all-plugins"); } async function installPluginFromRemote(url: string) { return await ipcRenderer.invoke("@shared/plugin-manager/install-plugin-remote", url); } async function installPluginFromLocal(url: string) { return await ipcRenderer.invoke("@shared/plugin-manager/install-plugin-local", url); } const mod = { onPluginUpdated, callPluginMethod, reloadPlugins, uninstallPlugin, updateAllPlugins, installPluginFromLocal, installPluginFromRemote, }; contextBridge.exposeInMainWorld("@shared/plugin-manager", mod); ================================================ FILE: src/shared/plugin-manager/renderer.ts ================================================ import Store from "@/common/store"; import AppConfig from "@shared/app-config/renderer"; import useAppConfig from "@/hooks/useAppConfig"; import { useMemo } from "react"; interface IPluginDelegateLike { platform?: string; hash?: string; } interface IMod { onPluginUpdated: (callback: (plugins: IPlugin.IPluginDelegate[]) => void) => void, callPluginMethod< T extends keyof IPlugin.IPluginInstanceMethods, >( pluginDelegate: IPluginDelegateLike, method: T, ...args: Parameters ): ReturnType, reloadPlugins: () => Promise; uninstallPlugin: (hash: string) => Promise; updateAllPlugins: () => Promise; installPluginFromRemote: (url: string) => Promise, installPluginFromLocal: (rawCode: string) => Promise, } const mod = window["@shared/plugin-manager" as any] as unknown as IMod; const delegatePluginsStore = new Store([]); mod.onPluginUpdated((plugins) => { delegatePluginsStore.setValue(plugins); }); function getSupportedPlugin( featureMethod: keyof IPlugin.IPluginInstanceMethods, ) { return delegatePluginsStore .getValue() .filter((_) => _.supportedMethod.includes(featureMethod)); } function getSortedSupportedPlugin( featureMethod: keyof IPlugin.IPluginInstanceMethods, ) { const meta = AppConfig.getConfig("private.pluginMeta") ?? {}; return delegatePluginsStore .getValue() .filter((_) => _.supportedMethod.includes(featureMethod)) .sort((a, b) => { return (meta[a.platform]?.order ?? Infinity) - (meta[b?.platform]?.order ?? Infinity) < 0 ? -1 : 1; }); } function getSearchablePlugins( supportedSearchType?: IMedia.SupportMediaType, ) { return getSupportedPlugin("search").filter((_) => supportedSearchType && _.supportedSearchType ? _.supportedSearchType.includes(supportedSearchType) : true, ); } function getSortedSearchablePlugins( supportedSearchType?: IMedia.SupportMediaType, ) { return getSortedSupportedPlugin("search").filter((_) => supportedSearchType && _.supportedSearchType ? _.supportedSearchType.includes(supportedSearchType) : true, ); } function getPluginByHash(hash: string) { return delegatePluginsStore.getValue().find((item) => item.hash === hash); } function getPluginByPlatform(platform: string) { return delegatePluginsStore.getValue().find((item) => item.platform === platform); } function isSupportFeatureMethod(platform: string, featureMethod: keyof IPlugin.IPluginInstanceMethods) { if (!platform) { return false; } return delegatePluginsStore.getValue().find((item) => item.platform === platform)?.supportedMethod?.includes?.(featureMethod) ?? false; } function getPluginPrimaryKey(pluginItem: IPluginDelegateLike) { return ( delegatePluginsStore .getValue() .find((it) => it.platform === pluginItem.platform)?.primaryKey ?? [] ); } async function setup() { await mod.reloadPlugins(); } const PluginManager = { setup, getSortedSupportedPlugin, getSupportedPlugin, getSearchablePlugins, getSortedSearchablePlugins, getPluginByHash, getPluginByPlatform, isSupportFeatureMethod, getPluginPrimaryKey, callPluginDelegateMethod: mod.callPluginMethod, updateAllPlugins: mod.updateAllPlugins, uninstallPlugin: mod.uninstallPlugin, installPluginFromRemote: mod.installPluginFromRemote, installPluginFromLocal: mod.installPluginFromLocal, }; export default PluginManager; export function useSupportedPlugin( featureMethod: keyof IPlugin.IPluginInstanceMethods, ) { return delegatePluginsStore .useValue() .filter((_) => _.supportedMethod.includes(featureMethod)); } export function useSortedSupportedPlugin( featureMethod: keyof IPlugin.IPluginInstanceMethods, ) { const meta = AppConfig.getConfig("private.pluginMeta") ?? {}; return delegatePluginsStore .useValue() .filter((_) => _.supportedMethod.includes(featureMethod)) .sort((a, b) => { return (meta[a.platform]?.order ?? Infinity) - (meta[b?.platform]?.order ?? Infinity) < 0 ? -1 : 1; }); } export function useSortedPlugins() { const plugins = delegatePluginsStore.useValue(); const meta = useAppConfig("private.pluginMeta") ?? {}; return useMemo(() => { return [...plugins].sort((a, b) => { return (meta[a.platform]?.order ?? Infinity) - (meta[b?.platform]?.order ?? Infinity) < 0 ? -1 : 1; }); }, [plugins, meta]); } ================================================ FILE: src/shared/service-manager/common.ts ================================================ export enum ServiceName { RequestForwarder = "request-forwarder", } ================================================ FILE: src/shared/service-manager/main.ts ================================================ import { ChildProcess, fork } from "child_process"; import { app, ipcMain } from "electron"; import { IWindowManager } from "@/types/main/window-manager"; import { ServiceName } from "@shared/service-manager/common"; import getResourcePath from "@/common/get-resource-path"; class ServiceInstance { private serviceProcess: ChildProcess = null; private retryTimeOut = 6000; private started = false; private subprocessName: string; private hostChangeCallback: (host: string | null) => void; public serviceName: string; constructor(serviceName: string, subprocessPath: string) { this.serviceName = serviceName; this.subprocessName = subprocessPath; } onHostChange(callback: (host: string | null) => void) { this.hostChangeCallback = callback; } start() { if (this.started) { return; } this.started = true; const servicePath = getResourcePath(".service/" + this.subprocessName + ".js"); this.serviceProcess = fork(servicePath); interface IMessage { type: "port", port: number } this.serviceProcess.on("message", (msg: IMessage) => { const host = "http://127.0.0.1:" + msg.port; this.hostChangeCallback(host); }); this.serviceProcess.on("error", () => { if (this.started) { setTimeout(() => { this.start(); // 自动重启子进程 }, this.retryTimeOut); this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2; } }); this.serviceProcess.on("exit", (code) => { if (this.started) { console.error(`Service exited with code ${code}. Restarting...`); setTimeout(() => { this.start(); // 自动重启子进程 }, this.retryTimeOut); this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2; } }); } stop() { this.started = false; if (!this.serviceProcess.killed) { this.serviceProcess.removeAllListeners(); this.serviceProcess.kill(); this.serviceProcess = null; this.retryTimeOut = 6000; this.hostChangeCallback(null); } } } interface IServiceData { instance: ServiceInstance; host: string | null; } class ServiceManager { private windowManager: IWindowManager; private serviceMap = new Map(); private addService(serviceName: ServiceName) { const instance = new ServiceInstance(serviceName, serviceName); this.serviceMap.set(serviceName, { instance, host: null }); instance.onHostChange((host) => { const mainWindow = this.windowManager?.mainWindow; if (mainWindow) { mainWindow.webContents.send("@shared/service-manager/host-changed", serviceName, host); } this.serviceMap.get(serviceName).host = host; }); return instance; } startService(serviceName: ServiceName) { this.serviceMap.get(serviceName)?.instance?.start?.(); } stopService(serviceName: ServiceName) { this.serviceMap.get(serviceName)?.instance?.stop?.(); } setup(windowManager: IWindowManager) { this.windowManager = windowManager; app.on("before-quit", () => { if (!windowManager.mainWindow?.isDestroyed()) { this.serviceMap.forEach((val) => { val.instance.stop(); }); } }); // put services here this.addService(ServiceName.RequestForwarder).start(); ipcMain.handle("@shared/service-manager/get-service-hosts", () => { const serviceHosts: Record = {}; this.serviceMap.forEach((val, key) => { if (val.host) { serviceHosts[key] = val.host; } }); return serviceHosts; }); } } export default new ServiceManager(); ================================================ FILE: src/shared/service-manager/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import { ServiceName } from "@shared/service-manager/common"; const serviceHostMap = new Map(); ipcRenderer.on("@shared/service-manager/host-changed", (_evt, serviceName: ServiceName, host: string | null) => { if (host) { serviceHostMap.set(serviceName, host); } else { serviceHostMap.delete(serviceName); } }); async function setup() { const hosts = (await ipcRenderer.invoke("@shared/service-manager/get-service-hosts")) || {}; const serviceNames = Object.keys(hosts); for (const serviceName of serviceNames) { serviceHostMap.set(serviceName as any, hosts[serviceName]); } } function getServiceHost(serviceName: ServiceName) { return serviceHostMap.get(serviceName); } const mod = { setup, getServiceHost, }; contextBridge.exposeInMainWorld("@shared/service-manager", mod); ================================================ FILE: src/shared/service-manager/renderer.ts ================================================ import { ServiceName } from "@shared/service-manager/common"; interface IMod { setup: () => Promise; getServiceHost: (serviceName: ServiceName) => string | null; } const mod = window["@shared/service-manager" as any] as unknown as IMod; class RequestForwarderService { static forwardRequest(url: string, method?: string, headers?: Record): string | null { const host = mod.getServiceHost(ServiceName.RequestForwarder); if (!host) { return null; } const fUrl = new URL(host); fUrl.searchParams.set("url", url); if (method) { fUrl.searchParams.set("method", method); } if (headers) { fUrl.searchParams.set("headers", JSON.stringify(headers)); } return fUrl.toString(); } } const ServiceManager = { setup: mod.setup, RequestForwarderService, }; export default ServiceManager; ================================================ FILE: src/shared/short-cut/main.ts ================================================ import { globalShortcut, ipcMain } from "electron"; import AppConfig from "@shared/app-config/main"; import { IAppConfig } from "@/types/app-config"; import { shortCutKeys, shortCutKeysCommands } from "@/common/constant"; import messageBus from "@shared/message-bus/main"; type IShortCutKeys = keyof IAppConfig["shortCut.shortcuts"]; class ShortCut { async setup() { await this.registerAllGlobalShortCuts(); ipcMain.on("@shared/short-cut/register-global-short-cut", async (_, key, shortCut) => { await this.registerGlobalShortCut(key, shortCut); }); ipcMain.on("@shared/short-cut/unregister-global-short-cut", async (_, key) => { await this.unregisterGlobalShortCut(key); }); } public async registerAllGlobalShortCuts() { try { const shortCuts = AppConfig.getConfig("shortCut.shortcuts"); for (const shortCutKey of shortCutKeys) { const globalShortCutConfig = shortCuts?.[shortCutKey]?.global; if (globalShortCutConfig?.length) { await this.registerGlobalShortCut(shortCutKey, globalShortCutConfig); } } } catch { // pass; } } public unregisterAllGlobalShortCuts() { globalShortcut.unregisterAll(); } public async registerGlobalShortCut(key: IShortCutKeys, shortCut: string[]) { try { if (shortCut.length) { // 1. 取之前的快捷键 const prevConfig = AppConfig.getConfig("shortCut.shortcuts"); if (prevConfig?.[key]?.global?.length) { globalShortcut.unregister(prevConfig[key].global.join("+")); } // 2. 注册新的快捷键 const reg = globalShortcut.register(shortCut.join("+"), () => { messageBus.sendCommand(shortCutKeysCommands[key]); }); // 3. 合并配置 const newConfig = { ...(prevConfig || {} as any), [key]: { ...(prevConfig?.[key] || {}), global: reg ? shortCut : null, }, }; // 4. 更新配置 AppConfig.setConfig({ "shortCut.shortcuts": newConfig, }); } } catch { // pass } } public async unregisterGlobalShortCut(key: IShortCutKeys) { const prevShortCut = AppConfig.getConfig("shortCut.shortcuts")?.[key]?.global; if (prevShortCut?.length) { // 1. 注销快捷键 globalShortcut.unregister(prevShortCut.join("+")); // 2. 更新配置 const prevConfig = AppConfig.getConfig("shortCut.shortcuts"); const newConfig = { ...(prevConfig || {} as any), [key]: { ...(prevConfig?.[key] || {}), global: null, }, } as IAppConfig["shortCut.shortcuts"]; AppConfig.setConfig({ "shortCut.shortcuts": newConfig, }); } } } const shortCut = new ShortCut(); export default shortCut; ================================================ FILE: src/shared/short-cut/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; function registerGlobalShortCut(key: string, shortCut: string[]) { ipcRenderer.send("@shared/short-cut/register-global-short-cut", key, shortCut); } function unregisterGlobalShortCut(key: string) { ipcRenderer.send("@shared/short-cut/unregister-global-short-cut", key); } const mod = { registerGlobalShortCut, unregisterGlobalShortCut, }; contextBridge.exposeInMainWorld("@shared/short-cut", mod); ================================================ FILE: src/shared/short-cut/renderer.ts ================================================ import AppConfig from "@shared/app-config/renderer"; import { IAppConfig } from "@/types/app-config"; import { shortCutKeys, shortCutKeysCommands } from "@/common/constant"; import hotkeys from "hotkeys-js"; import messageBus from "@shared/message-bus/renderer/main"; type IShortCutKeys = keyof IAppConfig["shortCut.shortcuts"]; interface IMod { registerGlobalShortCut: (key: IShortCutKeys, shortCut: string[]) => void; unregisterGlobalShortCut: (key: IShortCutKeys) => void; } const mod = window["@shared/short-cut" as any] as unknown as IMod; const originalHotkeysFilter = hotkeys.filter; hotkeys.filter = (event) => { const target = event.target as HTMLElement; if (target.dataset["capture"] === "true") { return true; } return originalHotkeysFilter(event); }; class ShortCut { private localShortCutCallbackMap = new Map void>(); setup() { try { const shortCuts = AppConfig.getConfig("shortCut.shortcuts"); for (const shortCutKey of shortCutKeys) { const localShortCutConfig = shortCuts?.[shortCutKey]?.local; if (localShortCutConfig?.length) { this.registerLocalShortCut(shortCutKey, localShortCutConfig); } } } catch { // pass } } registerLocalShortCut(key: IShortCutKeys, shortCut: string[]) { if (!shortCut?.length) { return; } this.unregisterLocalShortCut(key); const callback = (evt: KeyboardEvent) => { if (AppConfig.getConfig("shortCut.enableLocal")) { evt.preventDefault(); messageBus.sendCommand(shortCutKeysCommands[key]); } }; this.localShortCutCallbackMap.set(key as string, callback); hotkeys(shortCut.join("+"), "all", callback); const shortCuts = AppConfig.getConfig("shortCut.shortcuts"); AppConfig.setConfig({ "shortCut.shortcuts": { ...(shortCuts || {} as any), [key]: { ...(shortCuts?.[key] || {}), local: shortCut, }, }, }); } unregisterLocalShortCut(key: IShortCutKeys) { const shortCuts = AppConfig.getConfig("shortCut.shortcuts"); const prevShortCut = shortCuts?.[key]?.local; if (prevShortCut?.length) { hotkeys.unbind(prevShortCut.join("+"), "all", this.localShortCutCallbackMap.get(key as string)); this.localShortCutCallbackMap.delete(key as string); AppConfig.setConfig({ "shortCut.shortcuts": { ...(shortCuts || {} as any), [key]: { ...(shortCuts[key] || {}), local: null, }, }, }); } } registerGlobalShortCut(key: IShortCutKeys, shortCut: string[]) { mod.registerGlobalShortCut(key, shortCut); } unregisterGlobalShortCut(key: IShortCutKeys) { mod.unregisterGlobalShortCut(key); } } const shortCut = new ShortCut(); export default shortCut; ================================================ FILE: src/shared/themepack/main.ts ================================================ export default {}; ================================================ FILE: src/shared/themepack/preload.ts ================================================ import { addFileScheme, addTailSlash } from "@/common/file-util"; import path from "path"; import fs from "fs/promises"; import { Readable } from "stream"; import { rimraf } from "rimraf"; import { nanoid } from "nanoid"; import { createReadStream, createWriteStream } from "original-fs"; import unzipper from "unzipper"; import { getGlobalContext } from "../global-context/preload"; import { contextBridge } from "electron"; import CryptoJS from "crypto-js"; import debounce from "@/common/debounce"; const themeNodeId = "themepack-node"; const themePathKey = "themepack-path"; const validIframeMap = new Map< "app" | "header" | "body" | "music-bar" | "side-bar" | "page", HTMLIFrameElement | null >([ ["app", null], ["header", null], ["body", null], ["music-bar", null], ["side-bar", null], ["page", null], ]); const themePackBasePath: string = path.resolve( getGlobalContext().appPath.userData, "./musicfree-themepacks", ); /** * TODO: iframe需要运行在独立的进程中,不然会影响到app的fps 得想个办法 */ /** 选择某个主题 */ async function selectTheme(themePack: ICommon.IThemePack | null) { const themeNode = document.querySelector(`#${themeNodeId}`); if (themePack === null) { // 移除 themeNode.innerHTML = ""; validIframeMap.forEach((value, key) => { if (value !== null) { value.remove(); validIframeMap.set(key, null); } }); localStorage.removeItem(themePathKey); } else { const rawStyle = await fs.readFile( path.resolve(themePack.path, "index.css"), "utf-8", ); themeNode.innerHTML = replaceAlias(rawStyle, themePack.path); if (themePack.iframe) { validIframeMap.forEach(async (value, key) => { const themePackIframeSource = themePack.iframe[key]; if (themePackIframeSource) { // 如果有,且当前也有 let iframeNode = null; if (value !== null) { // 移除旧的 value.remove(); validIframeMap.set(key, null); } // 新的iframe iframeNode = document.createElement("iframe"); iframeNode.scrolling = "no"; document.querySelector(`.${key}-container`)?.prepend?.(iframeNode); validIframeMap.set(key, iframeNode); if (themePackIframeSource.startsWith("http")) { iframeNode.src = themePackIframeSource; } else { const rawHtml = await fs.readFile( replaceAlias(themePackIframeSource, themePack.path, false), "utf-8", ); iframeNode.contentWindow.document.open(); iframeNode.contentWindow.document.write( replaceAlias(rawHtml, themePack.path), ); iframeNode.contentWindow.document.close(); } } else if (value) { value.remove(); validIframeMap.set(key, null); } }); } else { validIframeMap.forEach((value, key) => { if (value !== null) { value.remove(); validIframeMap.set(key, null); } }); } localStorage.setItem(themePathKey, themePack.path); } } /** 替换标记 */ function replaceAlias( rawText: string, basePath: string, withFileScheme = true, ) { return rawText.replaceAll( "@/", addTailSlash(withFileScheme ? addFileScheme(basePath) : basePath), ); } async function checkPath() { // 路径: try { const res = await fs.stat(themePackBasePath); if (!res.isDirectory()) { await rimraf(themePackBasePath); throw new Error(); } } catch { fs.mkdir(themePackBasePath, { recursive: true, }); } } const downloadResponse = async (response: Response, filePath: string) => { const reader = response.body.getReader(); let size = 0; return new Promise((resolve, reject) => { const rs = new Readable(); rs._read = async () => { const result = await reader.read(); if (!result.done) { rs.push(Buffer.from(result.value)); size += result.value.byteLength; } else { rs.push(null); return; } }; rs.on("error", reject); const stm = rs.pipe(createWriteStream(filePath)); stm.on("finish", resolve); stm.on("close", resolve); stm.on("error", reject); }); }; async function parseThemePack( themePackPath: string, ): Promise { try { if (!themePackPath) { return null; } const packContent = await fs.readdir(themePackPath); if ( !( packContent.includes("config.json") && packContent.includes("index.css") ) ) { throw new Error("Not Valid Theme Pack"); } const rawConfig = await fs.readFile( path.resolve(themePackPath, "config.json"), "utf-8", ); // 读取json const jsonData = JSON.parse(rawConfig); const themePack: ICommon.IThemePack = { ...jsonData, hash: CryptoJS.MD5(rawConfig).toString(CryptoJS.enc.Hex), preview: jsonData.preview?.startsWith?.("#") ? jsonData.preview : jsonData.preview?.replace?.( "@/", addTailSlash(addFileScheme(themePackPath)), ), path: themePackPath, }; return themePack; } catch (e) { console.warn(e); return null; } } /** 加载所有的主题包 */ async function initCurrentTheme() { try { await checkPath(); const currentThemePath = localStorage.getItem(themePathKey); console.log(currentThemePath, themePathKey); const currentTheme: ICommon.IThemePack | null = await parseThemePack( currentThemePath, ); return currentTheme; } catch (e) { return null; } } async function loadThemePacks() { const themePackDirNames = await fs.readdir(themePackBasePath); // 读取所有的文件夹 const parsedThemePacks: ICommon.IThemePack[] = []; for (const themePackDir of themePackDirNames) { try { const parsedThemePack = await parseThemePack( path.resolve(themePackBasePath, themePackDir), ); if (parsedThemePack) { parsedThemePacks.push(parsedThemePack); } } catch {} } return parsedThemePacks; } async function installRemoteThemePack(remoteUrl: string) { const cacheFilePath = path.resolve( getGlobalContext().appPath.temp, `./${nanoid()}.mftheme`, ); try { const resp = await fetch(remoteUrl); await downloadResponse(resp, cacheFilePath); const config = await installThemePack(cacheFilePath); if (!config) { throw new Error("Download fail"); } return config; } catch (e: any) { throw e; } finally { await rimraf(cacheFilePath); } } async function installThemePack(themePackPath: string) { // 第一步: 移动到安装文件夹 try { const cacheFolder = path.resolve(themePackBasePath, nanoid(12)); await createReadStream(themePackPath) .pipe( unzipper.Extract({ path: cacheFolder, }), ) .promise(); const parsedThemePack = await parseThemePack(cacheFolder); if (parsedThemePack) { parsedThemePack.path = cacheFolder; return parsedThemePack; } else { // 无效的主题包 await rimraf(cacheFolder); return null; } } catch (e) { return null; } } async function uninstallThemePack(themePack: ICommon.IThemePack) { return await rimraf(themePack.path); } export const mod = { selectTheme, initCurrentTheme, loadThemePacks, installThemePack, uninstallThemePack, installRemoteThemePack, replaceAlias, }; contextBridge.exposeInMainWorld("@shared/themepack", mod); ================================================ FILE: src/shared/themepack/renderer.ts ================================================ import Store from "@/common/store"; import type { IMod } from "./type"; import { toast } from "react-toastify"; import { useEffect } from "react"; import debounce from "@/common/debounce"; const mod = window["@shared/themepack" as any] as unknown as IMod; // 所有本地主题包 const localThemePacksStore = new Store>([]); // 当前选中的主题包 const currentThemePackStore = new Store(null); async function selectTheme(themePack: ICommon.IThemePack | null) { if (!themePack?.hash) { themePack = null; } await mod.selectTheme(themePack); currentThemePackStore.setValue(themePack); } async function selectThemeByHash(hash: string) { const targetTheme = localThemePacksStore .getValue() .find((it) => it.hash === hash); if (targetTheme) { await mod.selectTheme(targetTheme); currentThemePackStore.setValue(targetTheme); } } let themePacksLoaded = false; async function setupThemePacks() { try { const currentTheme = await mod.initCurrentTheme(); // 选中主题 await selectTheme(currentTheme); // 调度 requestIdleCallback(() => { if (!themePacksLoaded) { mod.loadThemePacks(); } }); window.onresize = debounce(() => { mod.selectTheme(currentThemePackStore.getValue()); }, 150, { leading: false, trailing: true, }); } catch { // pass } } async function loadThemePacks() { themePacksLoaded = true; const themePacks = await mod.loadThemePacks(); localThemePacksStore.setValue(themePacks); } async function installThemePack(themePackPath: string) { const themePackConfig = await mod.installThemePack(themePackPath); if (themePackConfig) { localThemePacksStore.setValue((prev) => [...prev, themePackConfig]); } return themePackConfig; } /** * * @param remoteUrl 主题包地址 * @param id 可选,如果有主题id的话会替换掉本地的资源 * @returns */ async function installRemoteThemePack(remoteUrl: string, id?: string) { const themePackConfig = await mod.installRemoteThemePack(remoteUrl); let oldThemeConfig: ICommon.IThemePack | null = null; if (id) { oldThemeConfig = localThemePacksStore.getValue().find((it) => it.id === id); if (oldThemeConfig) { mod.uninstallThemePack(oldThemeConfig); } } if (themePackConfig) { localThemePacksStore.setValue((prev) => [themePackConfig].concat( oldThemeConfig ? prev.filter((it) => it.hash !== oldThemeConfig.hash) : prev, ), ); } return themePackConfig; } async function uninstallThemePack(themePack: ICommon.IThemePack) { try { await mod.uninstallThemePack(themePack); localThemePacksStore.setValue((prev) => prev.filter((it) => it?.path !== themePack.path), ); if (currentThemePackStore.getValue()?.path === themePack.path) { selectTheme(null); } } catch { toast.error("卸载失败"); } } function useLocalThemePacks() { const val = localThemePacksStore.useValue(); useEffect(() => { if (!themePacksLoaded) { loadThemePacks(); } }, []); return val; } const ThemePack = { selectTheme, selectThemeByHash, setupThemePacks, loadThemePacks, installThemePack, installRemoteThemePack, uninstallThemePack, replaceAlias: mod.replaceAlias, useLocalThemePacks, useCurrentThemePack: currentThemePackStore.useValue, }; export default ThemePack; ================================================ FILE: src/shared/themepack/type.d.ts ================================================ export type IMod = typeof import("./preload").mod; ================================================ FILE: src/shared/utils/main.ts ================================================ import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { IWindowManager } from "@/types/main/window-manager"; import fs from "fs/promises"; import { appUpdateSources } from "@/common/constant"; import axios from "axios"; import { compare } from "compare-versions"; class Utils { private windowManager: IWindowManager; public setup(windowManager: IWindowManager) { this.windowManager = windowManager; this.setupAppUtil(); this.setupWindowUtil(); this.setupShellUtil(); this.setupDialogUtil(); } private setupAppUtil() { ipcMain.on("@shared/utils/exit-app", () => { app.exit(0); }); ipcMain.handle("@shared/utils/app-get-path", (_, pathName) => { return app.getPath(pathName); }); ipcMain.handle("@shared/utils/check-update", async () => { const currentVersion = app.getVersion(); const updateInfo: ICommon.IUpdateInfo = { version: currentVersion, }; for (let i = 0; i < appUpdateSources.length; ++i) { try { const rawInfo = (await axios.get(appUpdateSources[i])).data; if (compare(rawInfo.version, currentVersion, ">")) { updateInfo.update = rawInfo; return updateInfo; } } catch { // pass } } return updateInfo; }); ipcMain.on("@shared/utils/clear-cache", () => { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { mainWindow.webContents.session.clearCache?.(); } }); ipcMain.handle("@shared/utils/get-cache-size", async () => { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { return mainWindow.webContents.session.getCacheSize?.(); } return NaN; }); } private setupWindowUtil() { ipcMain.on("@shared/utils/min-main-window", (_, { skipTaskBar }) => { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { if (skipTaskBar) { mainWindow.hide(); mainWindow.setSkipTaskbar(true); } else { mainWindow.minimize(); } } }); ipcMain.on("@shared/utils/show-main-window", () => { this.windowManager.showMainWindow(); }); ipcMain.on("@shared/utils/set-lyric-window", (_, enabled) => { if (enabled) { this.windowManager.showLyricWindow(); } else { this.windowManager.closeLyricWindow(); } }); ipcMain.on("@shared/utils/set-minimode-window", (_, enabled) => { if (enabled) { this.windowManager.showMiniModeWindow(); } else { this.windowManager.closeMiniModeWindow(); } }); ipcMain.on("@shared/utils/ignore-mouse-event", (evt, ignore) => { const targetWindow = BrowserWindow.fromWebContents(evt.sender); if (!targetWindow) { return; } targetWindow.setIgnoreMouseEvents(ignore, { forward: true, }); }); ipcMain.on("@shared/utils/toggle-maximize-main-window", () => { const mainWindow = this.windowManager.mainWindow; if (mainWindow) { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow.maximize(); } } }); ipcMain.on("@shared/utils/toggle-main-window-visible", () => { const mainWindow = this.windowManager.mainWindow; if (mainWindow.isMinimized() || !mainWindow.isVisible()) { mainWindow.show(); } else { mainWindow.hide(); mainWindow.setSkipTaskbar(true); } }); } private setupShellUtil() { ipcMain.on("@shared/utils/open-url", (_, url) => { shell.openExternal(url); }); ipcMain.on("@shared/utils/open-path", (_, path) => { shell.openPath(path); }); ipcMain.handle("@shared/utils/show-item-in-folder", async (_, path) => { try { await fs.stat(path); shell.showItemInFolder(path); return true; } catch { return false; } }); } private setupDialogUtil() { ipcMain.handle("@shared/utils/show-open-dialog", async (_, options) => { const mainWindow = this.windowManager.mainWindow; if (!mainWindow) { throw new Error("Invalid Window"); } return dialog.showOpenDialog(options); }); ipcMain.handle("@shared/utils/show-save-dialog", async (_, options) => { const mainWindow = this.windowManager.mainWindow; if (!mainWindow) { throw new Error("Invalid Window"); } return dialog.showSaveDialog(options); }); } } export default new Utils(); ================================================ FILE: src/shared/utils/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; import fs from "fs/promises"; import { rimraf } from "rimraf"; import url from "url"; /****** fs utils ******/ const originalFsWriteFile = fs.writeFile; const originalFsReadFile = fs.readFile; function writeFile(...args: Parameters): ReturnType { return originalFsWriteFile(...args); } function readFile(...args: Parameters): ReturnType { return originalFsReadFile(...args); } async function isFile(path: string) { try { const stat = await fs.stat(path); return stat.isFile(); } catch { return false; } } async function isFolder(path: string) { try { const stat = await fs.stat(path); return stat.isDirectory(); } catch { return false; } } function addFileScheme(filePath: string) { return filePath.startsWith("file:") ? filePath : url.pathToFileURL(filePath).toString(); } const fsUtil = { writeFile, readFile, isFile, isFolder, rimraf, addFileScheme, }; /****** app utils *****/ function exitApp() { ipcRenderer.send("@shared/utils/exit-app"); } async function getPath(pathName: "home" | "appData" | "userData" | "sessionData" | "temp" | "exe" | "module" | "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos" | "recent" | "logs" | "crashDumps") { return await ipcRenderer.invoke("@shared/utils/app-get-path", pathName); } async function checkUpdate() { return await ipcRenderer.invoke("@shared/utils/check-update"); } async function getCacheSize() { return await ipcRenderer.invoke("@shared/utils/get-cache-size"); } async function clearCache() { ipcRenderer.send("@shared/utils/clear-cache"); } const app = { exitApp, getPath, checkUpdate, getCacheSize, clearCache, }; /****** window utils *****/ function minMainWindow(skipTaskBar: boolean) { ipcRenderer.send("@shared/utils/min-main-window", { skipTaskBar }); } function showMainWindow() { ipcRenderer.send("@shared/utils/show-main-window"); } function setLyricWindow(enabled: boolean) { ipcRenderer.send("@shared/utils/set-lyric-window", enabled); } function setMinimodeWindow(enabled: boolean) { ipcRenderer.send("@shared/utils/set-minimode-window", enabled); } function ignoreMouseEvent(ignore: boolean) { ipcRenderer.send("@shared/utils/ignore-mouse-event", ignore); } function toggleMainWindowVisible() { ipcRenderer.send("@shared/utils/toggle-main-window-visible"); } function toggleMainWindowMaximize() { ipcRenderer.send("@shared/utils/toggle-maximize-main-window"); } const appWindow = { minMainWindow, showMainWindow, setLyricWindow, setMinimodeWindow, ignoreMouseEvent, toggleMainWindowVisible, toggleMainWindowMaximize, }; /****** shell utils *****/ function openExternal(url: string) { ipcRenderer.send("@shared/utils/open-url", url); } function openPath(path: string) { ipcRenderer.send("@shared/utils/open-path", path); } async function showItemInFolder(path: string): Promise { return await ipcRenderer.invoke("@shared/utils/show-item-in-folder", path); } const shell = { openExternal, openPath, showItemInFolder, }; /****** dialog utils *****/ function showOpenDialog(options: Electron.OpenDialogOptions): Promise { return ipcRenderer.invoke("@shared/utils/show-open-dialog", options); } function showSaveDialog(options: Electron.SaveDialogOptions): Promise { return ipcRenderer.invoke("@shared/utils/show-save-dialog", options); } const dialog = { showOpenDialog, showSaveDialog, }; const mod = { fs: fsUtil, app, appWindow, shell, dialog, }; contextBridge.exposeInMainWorld("@shared/utils", mod); ================================================ FILE: src/shared/utils/renderer.ts ================================================ import type fs from "fs/promises"; import type rimraf from "rimraf"; interface IMod { fs: { writeFile(...args: Parameters): ReturnType; readFile(...args: Parameters): ReturnType; isFile: (path: string) => Promise; isFolder: (path: string) => Promise; rimraf: typeof rimraf.rimraf; addFileScheme: (filePath: string) => string; }, app: { exitApp: () => void; getPath: (pathName: "home" | "appData" | "userData" | "sessionData" | "temp" | "exe" | "module" | "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos" | "recent" | "logs" | "crashDumps") => Promise; checkUpdate: () => Promise; clearCache: () => void; getCacheSize: () => Promise; } appWindow: { minMainWindow: (skipTaskBar?: boolean) => void; showMainWindow: () => void; setLyricWindow: (enabled: boolean) => void; setMinimodeWindow: (enabled: boolean) => void; setLyricWindowLock: (lockState: boolean) => void; ignoreMouseEvent: (ignore: boolean) => void; toggleMainWindowVisible: () => void; toggleMainWindowMaximize: () => void; }, shell: { openExternal: (url: string) => void; openPath: (path: string) => void; showItemInFolder: (path: string) => Promise; }, dialog: { showOpenDialog(options: Electron.OpenDialogOptions): Promise; showSaveDialog(options: Electron.SaveDialogOptions): Promise; } } const utils = window["@shared/utils" as any] as unknown as IMod; export default utils; export const { fs: fsUtil, app: appUtil, appWindow: appWindowUtil, shell: shellUtil, dialog: dialogUtil } = utils; ================================================ FILE: src/shared/window-drag/main.ts ================================================ /** * https://github.com/electron/electron/issues/1354#issuecomment-1356330873 */ import { BrowserWindow, ipcMain } from "electron"; import * as process from "node:process"; import debounce from "@/common/debounce"; const WM_MOUSEMOVE = 0x0200; // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousemove const WM_LBUTTONUP = 0x0202; // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-lbuttonup const MK_LBUTTON = 0x0001; interface IDragOptions { width: number; height: number; getWindowSize?: () => ICommon.ISize; onDragEnd: (position: ICommon.IPoint | null) => void; } const makeWin32WindowFullyDraggable = ( browserWindow: BrowserWindow, options: IDragOptions, ): void => { const { height, width, getWindowSize, onDragEnd } = options; const initialPos = { x: 0, y: 0, height, width, }; let dragging = false; let cachePosition: ICommon.IPoint | null = null; browserWindow.hookWindowMessage(WM_LBUTTONUP, () => { dragging = false; if (cachePosition !== null) { onDragEnd(cachePosition); } cachePosition = null; }); browserWindow.hookWindowMessage( WM_MOUSEMOVE, (wParam: Buffer, lParam: Buffer) => { if (!browserWindow) { return; } const wParamNumber: number = wParam.readInt16LE(0); if (!(wParamNumber & MK_LBUTTON)) { // <-- checking if left mouse button is pressed return; } const x = lParam.readInt16LE(0); const y = lParam.readInt16LE(2); if (!dragging) { dragging = true; let initWidth = width; let initHeight = height; if (getWindowSize) { const size = getWindowSize(); initWidth = size.width; initHeight = size.height; } initialPos.x = x; initialPos.y = y; initialPos.height = initHeight; initialPos.width = initWidth; return; } cachePosition = { x: x + browserWindow.getPosition()[0] - initialPos.x, y: y + browserWindow.getPosition()[1] - initialPos.y, }; browserWindow.setBounds({ x: cachePosition.x, y: cachePosition.y, height: initialPos.height, width: initialPos.width, }); }, ); }; class WindowDrag { private registeredWindows = new Map(); setup(): void { ipcMain.on("set-window-draggable", (_evt, position) => { const window = BrowserWindow.fromWebContents(_evt.sender); if (this.registeredWindows.has(window)) { const metadata = this.registeredWindows.get(window); let width = metadata.width; let height = metadata.height; if (metadata.getWindowSize) { const size = metadata.getWindowSize(); width = size.width; height = size.height; } window.setBounds({ x: position.x, y: position.y, height: height, width: width, }); metadata.onDragEnd?.(position); } }); } setWindowDraggable(window: BrowserWindow, options: IDragOptions): void { if (process.platform === "win32") { makeWin32WindowFullyDraggable(window, options); } else { const originalDragEnd = options.onDragEnd; options.onDragEnd = debounce((position: ICommon.IPoint | null) => { originalDragEnd?.(position); }, 300, { leading: false, trailing: true, }); this.registeredWindows.set(window, options); window.on("closed", () => { this.registeredWindows.delete(window); }); } } } export default new WindowDrag(); ================================================ FILE: src/shared/window-drag/preload.ts ================================================ import { contextBridge, ipcRenderer } from "electron"; function dragWindow(position: ICommon.IPoint) { ipcRenderer.send("set-window-draggable", position); } const mod = { dragWindow, }; contextBridge.exposeInMainWorld("@shared/window-drag", mod); ================================================ FILE: src/shared/window-drag/renderer.ts ================================================ import { getGlobalContext } from "@shared/global-context/renderer"; interface IMod { dragWindow(position: ICommon.IPoint): void; } const mod = window["@shared/window-drag" as any] as unknown as IMod; let startClientPos: ICommon.IPoint | null = null; let isMoving = false; let injected = false; function injectHandler() { const task = () => setTimeout(() => { if (injected) { return; } injected = true; if (getGlobalContext().platform !== "win32") { // win32使用make-window-fully-draggable方案 window.addEventListener("mousedown", (e) => { startClientPos = { x: e.clientX, y: e.clientY, }; isMoving = true; }); window.addEventListener("mousemove", (e) => { if (startClientPos && isMoving) { mod.dragWindow({ x: e.screenX - startClientPos.x, y: e.screenY - startClientPos.y, }); } }); window.addEventListener("mouseup", () => { isMoving = false; startClientPos = null; }); } }); if (document.readyState === "complete") { task(); } else { document.onload = task; } } const WindowDrag = { injectHandler, }; export default WindowDrag; ================================================ FILE: src/types/app-config.d.ts ================================================ interface _IAppConfig { "$schema-version": number; "normal.closeBehavior": "exit_app" | "minimize"; "normal.maxHistoryLength": number; "normal.checkUpdate": boolean; "normal.autoLoadMore": boolean; "normal.taskbarThumb": "window" | "artwork"; "normal.musicListColumnsShown": Array<"duration" | "platform">; "normal.language": string; /** 歌单内搜索区分大小写 */ "playMusic.caseSensitiveInSearch": boolean; /** 默认播放音质 */ "playMusic.defaultQuality": IMusic.IQualityKey; /** 默认播放音质缺失时 */ "playMusic.whenQualityMissing": "higher" | "lower" | "skip"; /** 双击音乐列表时 */ "playMusic.clickMusicList": "normal" | "replace"; /** 播放失败时 */ "playMusic.playError": "pause" | "skip"; /** 输出设备 */ "playMusic.audioOutputDevice": MediaDeviceInfo | null; /** 设备变化时 */ "playMusic.whenDeviceRemoved": "pause" | "play"; /** [darwin only] 显示状态栏歌词 */ "lyric.enableStatusBarLyric": boolean; /** 显示桌面歌词 */ "lyric.enableDesktopLyric": boolean; /** 桌面歌词置顶 */ "lyric.alwaysOnTop": boolean; /** 锁定桌面歌词 */ "lyric.lockLyric": boolean; /** 字体 */ "lyric.fontData": FontData; /** 字体颜色 */ "lyric.fontColor": string; /** 字体大小 */ "lyric.fontSize": number; /** 描边颜色 */ "lyric.strokeColor": string; /** 是否启用本地快捷键 */ "shortCut.enableLocal": boolean; /** 是否启用全局快捷键 */ "shortCut.enableGlobal": boolean; /** 快捷键映射 */ "shortCut.shortcuts": Record< | "play/pause" | "skip-previous" | "skip-next" | "toggle-desktop-lyric" | "volume-up" | "volume-down" | "like/dislike", | "toggle-main-window-visible", { local?: string[] | null; global?: string[] | null; } >; /** 下载路径 */ "download.path": string; /** 默认下载音质 */ "download.defaultQuality": IMusic.IQualityKey; /** 默认下载音质缺失时 */ "download.whenQualityMissing": "higher" | "lower"; /** 最多同时下载 */ "download.concurrency": number; /** 是否自动升级插件 */ "plugin.autoUpdatePlugin": boolean; /** 是否不检测插件版本 */ "plugin.notCheckPluginVersion": boolean; /** 是否启用代理 */ "network.proxy.enabled": boolean; "network.proxy.host": string; "network.proxy.port": string; "network.proxy.username": string; "network.proxy.password": string; /** 恢复歌单时行为 */ "backup.resumeBehavior": "append" | "overwrite"; /** URL */ "backup.webdav.url": string; /** 用户名 */ "backup.webdav.username": string; /** 密码 */ "backup.webdav.password": string; /** 本地音乐配置 */ "localMusic.watchDir": string[]; /** 不需要用户配置的数据 */ "private.mainWindowSize": ICommon.ISize; "private.lyricWindowPosition": ICommon.IPoint; "private.lyricWindowSize": ICommon.ISize; "private.minimodeWindowPosition": ICommon.IPoint; "private.pluginMeta": Record; "private.minimode": boolean; } type PartialOrNull = { [P in keyof T]?: T[P] | null }; export type IAppConfig = PartialOrNull<_IAppConfig>; export type IAppConfigKey = keyof IAppConfig; ================================================ FILE: src/types/assets.d.ts ================================================ type Styles = Record; declare module "*.svg" { export const ReactComponent: React.FC>; const content: string; export default content; } declare module "*.png" { const content: string; export default content; } declare module "*.jpg" { const content: string; export default content; } declare module "*.ico" { const content: string; export default content; } ================================================ FILE: src/types/audio-controller.d.ts ================================================ import { PlayerState } from "@/common/constant"; import { CurrentTime, ErrorReason } from "@renderer/core/track-player/enum"; export interface IAudioController { // 是否有音源 hasSource: boolean; playerState: PlayerState; musicItem: IMusic.IMusicItem | null; // 准备音乐信息 prepareTrack?(musicItem: IMusic.IMusicItem): void; // 设置音源 setTrackSource(trackSource: IMusic.IMusicSource, musicItem: IMusic.IMusicItem): void; // 暂停 pause(): void; // 播放 play(): void; // 设置音量 setVolume(volume: number): void; // 跳转 seekTo(seconds: number): void; // 设置循环 setLoop(isLoop: boolean): void; // 设置播放速度 setSpeed(speed: number): void; // 设置输出设备id setSinkId(deviceId: string): Promise; // 清空当前播放的歌曲 reset(): void; // 销毁audio实例 destroy(): void; onPlayerStateChanged?: (playerState: PlayerState) => void; // 进度更新 onProgressUpdate?: (progress: CurrentTime) => void; // 出错 onError?: (type: ErrorReason, error?: any) => void; // 播放结束 onEnded?: () => void; // 音量改变 onVolumeChange?: (volume: number) => void; // 速度改变 onSpeedChange?: (speed: number) => void; } ================================================ FILE: src/types/common.d.ts ================================================ declare namespace ICommon { export type WithMusicList = T & { musicList?: IMusic.IMusicItem[]; }; export type PaginationResponse = { isEnd?: boolean; data?: T[]; }; interface IUpdateInfo { version: string; update?: { version: string; changeLog: string[]; download: string[]; }; } interface IPoint { x: number; y: number; } interface ISize { width: number; height: number; } interface IThemePack { id?: string; /** 主题 */ name: string; /** 加载之后的路径,内部属性 */ hash?: string; path: string; /** 缩略图 */ thumb?: string; /** 预览图 */ preview: string; /** 主题更新链接 */ srcUrl?: string; /** 主题作者 */ author?: string; /** 版本号 */ version?: string; description?: string; iframe?: Record< "app" | "header" | "body" | "music-bar" | "side-bar" | "page", string >; } interface IDownloadFileSize { /** 当前下载的大小 */ currentSize?: number; /** 总大小 */ totalSize?: number; } type ICommonReturnType = [ boolean, { msg?: string; [k: string]: any; }? ]; interface ICommand { SetPlayerState: PlayerState; SkipToPrevious: void; SkipToNext: void; SetRepeatMode: RepeatMode; PlayMusic: IMusic.IMusicItem; } type ICommandKey = keyof ICommand; } ================================================ FILE: src/types/main/window-manager.d.ts ================================================ import { BrowserWindow } from "electron"; export type IWindowNames = "main" | "lyric" | "minimode"; export interface IWindowEvents { "WindowCreated": { windowName: IWindowNames; browserWindow: BrowserWindow; } } export interface IWindowManager { mainWindow: BrowserWindow | null; lyricWindow: BrowserWindow | null; miniModeWindow: BrowserWindow | null; /** * 获取主窗口的引用 */ getMainWindow(): BrowserWindow; /** * 获取所有扩展窗口的引用 */ getExtensionWindows(): BrowserWindow[]; /** * 获取所有窗口的引用 */ getAllWindows(): BrowserWindow[]; /** * 为特定事件类型注册监听器 */ on(event: T, listener: (data: IWindowEvents[T]) => void): void; /** * 显示主窗口 */ showMainWindow(): void; /** * 关闭主窗口 */ closeMainWindow(): void; /** * 显示歌词窗口 */ showLyricWindow(): void; /** * 关闭歌词窗口 */ closeLyricWindow(): void; /** * 显示迷你模式窗口 */ showMiniModeWindow(): void; /** * 关闭迷你模式窗口 */ closeMiniModeWindow(): void; } ================================================ FILE: src/types/media.d.ts ================================================ declare namespace IMedia { export type SupportMediaItem = { music: IMusic.IMusicItem; album: IAlbum.IAlbumItem; artist: IArtist.IArtistItem; sheet: IMusic.IMusicSheetItem; lyric: ILyric.ILyricItem; }; export type SupportMediaType = keyof SupportMediaItem; interface IUnique { /** 唯一id */ id: string; $?: any; [k: string | number | symbol]: any; } /** 基础媒体类型 */ interface IMediaBase extends IUnique { /** 媒体来源平台,如本地等 */ platform: string; [k: string | number | symbol]: any; } } declare namespace IMusic { interface IMusicSource { /** 播放的http请求头 */ headers?: Record; /** 兜底播放 */ url?: string; /** UA */ userAgent?: string; } interface IMusicItem extends IMedia.IMediaBase { /** 作者 */ artist: string; /** 歌曲标题 */ title: string; /** 时长(s) */ duration?: number; /** 专辑名 */ album?: string; /** 专辑封面图 */ artwork?: string; /** 默认音源 */ url?: string; // todo: 格式化 /** 歌词URL */ lrc?: string; /** 歌词文本 */ rawLrc?: string; // 其他 [k: string | number | symbol]: any; } // 音乐的内部数据 interface IMusicItemInternalData { downloadData?: { path: string; quality: IQualityKey; }; } interface IMusicSheetItem extends IMedia.IMediaBase { /** 封面图 */ artwork?: string; /** 标题 */ title: string; /** 描述 */ description?: string; /** 作品总数 */ worksNum?: number; /** 播放次数 */ playCount?: number; /** 播放列表 */ musicList?: IMusicItem[]; /** 歌单创建日期 */ createAt?: number; // 歌单作者 artist?: string; } /** 数据库中存储的歌单列表,其中音乐列表只存id */ interface IDBMusicSheetItem extends IMusicSheetItem { musicList?: IMedia.IMediaBase[]; } interface ILocalMusicList { folder: string; musicList: IMusic.IMusicItem[]; } /** 歌单集合 */ export interface IMusicSheetGroupItem { title?: string; data: Array; } // 音质 export type IQualityKey = "low" | "standard" | "high" | "super"; type IMusicItemPartial = Partial; } declare namespace IAlbum { interface IAlbumItem extends IMusic.IMusicSheetItem { artwork?: string; title: string; date?: string; artist?: string; description: string; /** 专辑内有多少作品 */ worksNum?: number; musicList?: IMusic.IMusicItem[]; } } declare namespace IArtist { interface IArtistItem { name: string; id: string; fans?: number; description?: string; platform: string; avatar: string; musicList?: IMusic.IMusicItem[]; albumList?: IAlbum.IAlbumItem[]; } type ArtistMediaType = "music" | "album"; } declare namespace ILyric { interface ILyricItem extends IMusic.IMusicItem { /** 歌词(无时间戳) */ rawLrcTxt?: string; } interface ILyricSource { lrc?: string; rawLrc?: string; translation?: string; } } declare namespace IComment { interface ICommentItem { id?: string; // 用户名 nickName: string; // 头像 avatar?: string; // 评论内容 comment: string; // 点赞数 like?: number; // 评论时间 createAt?: number; // 地址 location?: string; } interface IComment extends ICommentItem { // 回复 replies?: IComment[]; } } ================================================ FILE: src/types/model.d.ts ================================================ // 数据模型 declare namespace IDataBaseModel { export interface IMusicSheetModel { platform: string; id: string; /** 标题 */ title: string; /** 封面图 */ artwork?: string; /** 描述 */ description?: string; /** 作品总数 */ worksNum?: number; /** 播放次数 */ playCount?: number; /** 歌单创建日期 */ createAt?: number; // 歌单作者 artist?: string; // 原始数据 _raw: string; // 排序信息 _sortIndex: number; } export interface IMusicItemModel { platform: string; id: string; /** 作者 */ artist?: string; /** 歌曲标题 */ title: string; /** 时长(s) */ duration?: number; /** 专辑名 */ album?: string; /** 专辑封面图 */ artwork?: string; /** 添加到歌单的时间 */ _timestamp: number; // 完整信息 _raw: string; // 在歌单内的顺序 _sortIndex: number; // 歌单ID _musicSheetId: string; // 歌单 _musicSheetPlatform: string; } } ================================================ FILE: src/types/plugin.d.ts ================================================ declare namespace IPlugin { export interface IMediaSourceResult { headers?: Record; /** 兜底播放 */ url?: string; /** UA */ userAgent?: string; /** 音质 */ quality?: IMusic.IQualityKey; } export interface ISearchResult { isEnd?: boolean; data: IMedia.SupportMediaItem[T][]; } export type ISearchResultType = IMedia.SupportMediaType; type ISearchFunc = ( query: string, page: number, type: T ) => Promise>; type IGetArtistWorksFunc = ( artistItem: IArtist.IArtistItem, page: number, type: T ) => Promise>; interface IUserVariable { /** 变量键名 */ key: string; /** 变量名 */ name?: string; /** 提示文案 */ hint?: string; } interface IAlbumInfoResult { isEnd?: boolean; albumItem?: IAlbum.IAlbumItem; musicList?: IMusic.IMusicItem[]; } interface ISheetInfoResult { isEnd?: boolean; sheetItem?: IMusic.IMusicSheetItem; musicList?: IMusic.IMusicItem[]; } interface ITopListInfoResult { isEnd?: boolean; topListItem?: IMusic.IMusicSheetItem; musicList?: IMusic.IMusicItem[]; } interface IGetRecommendSheetTagsResult { // 固定的tag pinned?: IMusic.IMusicSheetItem[]; data?: IMusic.IMusicSheetGroupItem[]; } interface IGetCommentResult { isEnd?: boolean; data?: IComment.IComment[]; } interface IPluginDefine { /** 来源名 */ platform: string; /** 匹配的版本号 */ appVersion?: string; /** 插件版本 */ version?: string; /** 远程更新的url */ srcUrl?: string; /** 主键,会被存储到mediameta中 */ primaryKey?: string[]; /** 默认搜索类型 */ defaultSearchType?: IMedia.SupportMediaType; /** 有效搜索类型 */ supportedSearchType?: ICommon.SupportMediaType[]; /** 插件缓存控制 */ cacheControl?: "cache" | "no-cache" | "no-store"; /** 插件作者 */ author?: string; /** 用户自定义输入 */ userVariables?: IUserVariable[]; /** 提示文本 */ hints?: Record; /** 搜索 */ search?: ISearchFunc; /** 获取根据音乐信息获取url */ getMediaSource?: ( musicItem: IMusic.IMusicItemPartial, quality: IMusic.IQualityKey ) => Promise; /** 根据主键去查询歌曲信息 */ getMusicInfo?: ( musicBase: IMedia.IMediaBase ) => Promise | null>; /** 获取歌词 */ getLyric?: ( musicItem: IMusic.IMusicItemPartial ) => Promise; /** 获取专辑信息,里面的歌曲分页 */ getAlbumInfo?: ( albumItem: IAlbum.IAlbumItem, page: number ) => Promise; /** 获取歌单信息,有分页 */ getMusicSheetInfo?: ( sheetItem: IMusic.IMusicSheetItem, page: number ) => Promise; /** 获取作品,有分页 */ getArtistWorks?: IGetArtistWorksFunc; /** 导入歌单 */ // todo: 数据结构应该是IMusicSheetItem importMusicSheet?: (urlLike: string) => Promise; /** 导入单曲 */ importMusicItem?: (urlLike: string) => Promise; /** 获取榜单 */ getTopLists?: () => Promise; /** 获取榜单详情 */ getTopListDetail?: ( topListItem: IMusic.IMusicSheetItem, page: number ) => Promise; /** 获取热门歌单tag */ getRecommendSheetTags?: () => Promise; /** 歌单列表 */ getRecommendSheetsByTag?: ( tag: ICommon.IUnique, page?: number ) => Promise>; /** 歌曲评论 */ getMusicComments?: (musicItem: IMusic.IMusicItem, page?: number) => Promise } export interface IPluginInstance extends IPluginDefine { /** 内部属性 */ /** 插件路径 */ _path: string; } type R = Required; export type IPluginInstanceMethods = { [K in keyof R as R[K] extends (...args: any) => any ? K : never]: R[K]; }; /** 插件其他属性 */ export type IPluginMeta = { order?: number; userVariables?: Record; }; export type IPluginDelegate = { // 除去函数 [K in keyof R as R[K] extends (...args: any) => any ? never : K]: R[K]; } & { supportedMethod: string[]; hash: string; path: string; }; } ================================================ FILE: src/types/preload.d.ts ================================================ interface Window { path: typeof import("node:path"); } ================================================ FILE: src/types/user-perference.d.ts ================================================ declare namespace IUserPreference { interface IType { /** 重复模式 */ repeatMode: string; /** 当前进度 */ currentMusic: IMusic.IMusicItem; currentProgress: number; currentQuality: IMusic.IQualityKey; /** 当前音量 */ volume: number; /** 倍速 */ speed: number; /** 订阅 */ subscription: Array<{ title?: string; srcUrl: string; }>; skipVersion: string; inlineLyricFontSize: string; /** 展示翻译 */ showTranslation: boolean; } interface IDBType { /** 当前播放队列 */ playList: IMusic.IMusicItem[]; /** 最近播放队列 */ recentlyPlayList: IMusic.IMusicItem[]; /** 已下载列表 */ downloadedList: IMedia.IMediaBase[]; /** 本地音乐监听列表 */ localWatchDir: string[]; /** 本地音乐勾选的监听列表 */ localWatchDirChecked: string[]; /** 收藏的歌单 */ starredMusicSheets: IMedia.IMediaBase[]; /** 搜索历史 */ searchHistory: string[]; /** 插件数据 */ pluginMeta: Record; } } ================================================ FILE: src/types/window.d.ts ================================================ /** 某些没有类型的新特性 */ interface Window { /** 获取本地字体 */ queryLocalFonts: () => Promise } declare interface FontData { family: readonly string; fullName: readonly string; postscriptName: readonly string; style: readonly string; } ================================================ FILE: src/webworkers/db-worker/const.ts ================================================ export const TableName_SheetMusicListPrefix = "SHEET_MUSICLIST_"; /** 默认歌单的歌曲列表 */ export const TableName_DefaultSheetMusicList = "SHEET_MUSICLIST_favorite"; /** 本地歌单的歌曲列表 */ export const TableName_LocalSheetMusicList = "SHEET_MUSICLIST_local"; /** 本地歌单 */ export const TableName_LocalSheets = "localMusicSheets"; ================================================ FILE: src/webworkers/db-worker/index.ts ================================================ import * as Comlink from "comlink"; import * as chokidar from "chokidar"; import path from "path"; import { supportLocalMediaType } from "@/common/constant"; import debounce from "lodash.debounce"; import { parseLocalMusicItem } from "@/common/file-util"; import { setInternalData } from "@/common/media-util"; import { safeParse } from "@/common/safe-serialization"; import Database from "better-sqlite3"; let database: Database.Database; function setupWorker(dbPath: string) { database = new Database(dbPath); database.pragma("journal_mode = WAL"); } Comlink.expose({}); ================================================ FILE: src/webworkers/db-worker/utils.ts ================================================ import type { Database } from "better-sqlite3"; const validTableNameRegex = /^[a-zA-Z][a-zA-Z0-9_]*$/; function checkTableName(tableName: string) { if (!validTableNameRegex.test(tableName)) { throw new Error(`Invalid table name: ${tableName}`); } } /** * * @param database Database Instance * @param tableName tableName * @returns */ export function isTableExist(database: Database, tableName: string) { return !!database .prepare( "SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name=?", ) .get(tableName).cnt; } export function createMusicListTable(database: Database, tableName: string) { checkTableName(tableName); database .prepare( `CREATE TABLE IF NOT EXISTS "main"."${tableName}" ( "platform" TEXT NOT NULL, "id" text NOT NULL, "title" TEXT, "artist" TEXT, "artwork" TEXT, "url" TEXT, "lrc" TEXT, "album" TEXT, "extra" TEXT, "$sortIndex" INTEGER NOT NULL DEFAULT 0, "$raw" TEXT, PRIMARY KEY ("platform", "id") );`, ) .run(); } ================================================ FILE: src/webworkers/db-worker.ts ================================================ import * as Comlink from "comlink"; import * as chokidar from "chokidar"; import path from "path"; import { supportLocalMediaType } from "@/common/constant"; import debounce from "lodash.debounce"; import { parseLocalMusicItem } from "@/common/file-util"; import { setInternalData } from "@/common/media-util"; import { safeParse } from "@/common/safe-serialization"; import Database from "better-sqlite3"; const dbPath = ""; const database = new Database(dbPath); database.pragma("journal_mode = WAL"); function getSheetItem(sheetId: string): IMusic.IMusicSheetItem | null { try { const queryMusicListSql = database.prepare<[], IMusic.IMusicItem>( `SELECT * from "main"."${`SHEET_MUSICLIST_${sheetId}`}" ORDER BY "$sortIndex" DESC `, ); const sheetItem = database .prepare<[string], IMusic.IMusicSheetItem>( "SELECT * from \"main\".\"localMusicSheets\" where id = ?", ) .get(sheetId); return { platform: sheetItem.platform, id: sheetItem.id, title: sheetItem.title, artwork: sheetItem.artwork, description: sheetItem.description, createAt: sheetItem.createAt, musicList: queryMusicListSql.all().map((it: any) => ({ ...it, $raw: safeParse(it.raw), })), worksNum: sheetItem.worksNum, }; } catch { return null; } } Comlink.expose({ getSheetItem, }); ================================================ FILE: src/webworkers/downloader.ts ================================================ import * as Comlink from "comlink"; import fs from "fs"; import fsPromises from "fs/promises"; import { Readable } from "stream"; import { encodeUrlHeaders } from "@/common/normalize-util"; import throttle from "lodash.throttle"; import { DownloadState as DownloadState } from "@/common/constant"; import { rimraf } from "rimraf"; async function cleanFile(filePath: string) { try { if ((await fsPromises.stat(filePath)).isFile()) { await rimraf(filePath); } return true; } catch { return false; } } const responseToReadable = ( response: Response, options?: { onRead?: (size: number) => void; onDone?: () => void; onError?: (e: Error) => void; }, ) => { const reader = response.body.getReader(); const rs = new Readable(); let size = 0; const tOnRead = throttle(options?.onRead, 64, { leading: true, trailing: true, }); rs._read = async () => { const result = await reader.read(); if (!result.done) { rs.push(Buffer.from(result.value)); size += result.value.byteLength; tOnRead?.(size); } else { rs.push(null); options?.onDone?.(); return; } }; rs.on("error", options?.onError); return rs; }; type IOnStateChangeFunc = (data: { state: DownloadState; downloaded?: number; total?: number; msg?: string; }) => void; async function downloadFile( mediaSource: IMusic.IMusicSource, filePath: string, onStateChange: IOnStateChangeFunc, ) { let state = DownloadState.DOWNLOADING; try { const stat = fs.statSync(filePath); // if (stat.isFile()) { // state = DownloadState.ERROR; // onStateChange?.({ // state, // msg: "File Exist", // }); // return; // } if (stat.isDirectory()) { state = DownloadState.ERROR; onStateChange?.({ state, msg: "Filepath is a directory", }); return; } } catch (e) {} const _headers: Record = { ...(mediaSource.headers ?? {}), "user-agent": mediaSource.userAgent, }; try { const urlObj = new URL(mediaSource.url); let res: Response; if (urlObj.username && urlObj.password) { _headers["Authorization"] = `Basic ${btoa( `${decodeURIComponent(urlObj.username)}:${decodeURIComponent( urlObj.password, )}`, )}`; urlObj.username = ""; urlObj.password = ""; res = await fetch(urlObj.toString(), { headers: _headers, }); } else { res = await fetch(encodeUrlHeaders(mediaSource.url, _headers)); } const totalSize = +res.headers.get("content-length"); onStateChange({ state, downloaded: 0, total: totalSize, }); const stm = responseToReadable(res, { onRead(size) { if (state !== DownloadState.DOWNLOADING) { return; } state = DownloadState.DOWNLOADING; console.log(state, size, totalSize); onStateChange({ state, downloaded: size, total: totalSize, }); }, onError: (e) => { state = DownloadState.ERROR; onStateChange({ state, msg: e?.message, }); }, }).pipe(fs.createWriteStream(filePath)); stm.on("close", () => { state = DownloadState.DONE; onStateChange({ state, }); }); stm.on("error", () => { // 清理文件 cleanFile(filePath); }); } catch (e) { state = DownloadState.ERROR; onStateChange({ state, msg: e?.message, }); cleanFile(filePath); } } interface IOptions { onProgress?: (progress: ICommon.IDownloadFileSize) => Promise; onEnded?: () => Promise; onError?: (reason: Error) => Promise; } async function downloadFileNew( mediaSource: IMusic.IMusicSource, filePath: string, options?: IOptions, ) { let hasError = false; const { onProgress: onProgressCallback, onEnded: onEndedCallback, onError: onErrorCallback } = options ?? {}; try { const stat = fs.statSync(filePath); if (stat.isDirectory()) { hasError = true; onErrorCallback?.(new Error("Filepath is a directory")); return; } } catch (e) { // pass } const headers: Record = { ...(mediaSource.headers ?? {}), "user-agent": mediaSource.userAgent, }; try { const urlObj = new URL(mediaSource.url); let res: Response; if (urlObj.username && urlObj.password) { headers["Authorization"] = `Basic ${btoa( `${decodeURIComponent(urlObj.username)}:${decodeURIComponent( urlObj.password, )}`, )}`; urlObj.username = ""; urlObj.password = ""; res = await fetch(urlObj.toString(), { headers: headers, }); } else { res = await fetch(encodeUrlHeaders(mediaSource.url, headers)); } const totalSize = +res.headers.get("content-length"); onProgressCallback?.({ currentSize: 0, totalSize: totalSize, }); const stm = responseToReadable(res, { onRead(size) { if (hasError) { // todo abort return; } onProgressCallback?.({ currentSize: size, totalSize: totalSize, }); }, onError: (e) => { if (!hasError) { hasError = true; onErrorCallback?.(e); } }, }).pipe(fs.createWriteStream(filePath)); stm.on("close", () => { onEndedCallback?.(); }); stm.on("error", (e) => { if (!hasError) { hasError = true; onErrorCallback?.(e); } // 清理文件 cleanFile(filePath); }); } catch (e) { if (!hasError) { hasError = true; onErrorCallback?.(e); } cleanFile(filePath); } } Comlink.expose({ downloadFile, downloadFileNew, }); ================================================ FILE: src/webworkers/local-file-watcher.ts ================================================ import * as Comlink from "comlink"; import * as chokidar from "chokidar"; import path from "path"; import { supportLocalMediaType } from "@/common/constant"; import debounce from "lodash.debounce"; import { parseLocalMusicItem } from "@/common/file-util"; import { setInternalData } from "@/common/media-util"; let watcher: chokidar.FSWatcher; const addedMusicItems: IMusic.IMusicItem[] = []; const removedFilePaths: string[] = []; let _onAdd: (musicItems: IMusic.IMusicItem[]) => void; let _onRemove: (filePaths: string[]) => void; async function setupWatcher(initPaths?: string[]) { watcher = chokidar.watch(initPaths ?? [], { depth: 10, persistent: true, ignorePermissionErrors: true, }); watcher.on("add", async (fp, stats) => { if ( stats.isFile() && supportLocalMediaType.some((postfix) => fp.endsWith(postfix)) ) { const musicItem = await parseLocalMusicItem(fp); musicItem.$$localPath = fp; setInternalData( musicItem, "downloadData", { path: fp, quality: "standard", }, ); addedMusicItems.push(musicItem); syncAddedMusic(); } }); watcher.on("unlink", (fp) => { if (supportLocalMediaType.some((postfix) => fp.endsWith(postfix))) { removedFilePaths.push(fp); syncRemovedFilePaths(); } }); } const syncAddedMusic = debounce( () => { const copyOfAddedMusicItems = [...addedMusicItems]; addedMusicItems.length = 0; _onAdd?.(copyOfAddedMusicItems); }, 500, { leading: false, trailing: true, }, ); const syncRemovedFilePaths = debounce( () => { const copyOfRemovedFilePaths = [...removedFilePaths]; removedFilePaths.length = 0; _onRemove?.(copyOfRemovedFilePaths); }, 500, { leading: false, trailing: true, }, ); async function changeWatchPath(addPaths?: string[], rmPaths?: string[]) { console.log(addPaths, rmPaths); try { if (addPaths?.length) { watcher.add(addPaths); } if (rmPaths?.length) { watcher.unwatch(rmPaths); /** * chokidar的bug: https://github.com/paulmillr/chokidar/issues/1027 * unwatch之后重新watch不会触发文件更新 */ rmPaths.forEach((it) => { // @ts-ignore const watchedDirEntry = watcher._watched.get(it); if (watchedDirEntry) { // 移除所有子节点的监听 watchedDirEntry._removeWatcher( path.dirname(it), path.basename(it), true, ); } // watcher._watched.delete(it); }); } // console.log("WATCH PATH CHANGED", addPaths, rmPaths, watcher); } catch (e) { console.log(e); } } async function onAdd(fn: (musicItems: IMusic.IMusicItem[]) => void) { _onAdd = fn; } async function onRemove(fn: (filePaths: string[]) => void) { _onRemove = fn; } Comlink.expose({ setupWatcher, changeWatchPath, onAdd, onRemove, }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "jsx": "react-jsx", "target": "ES2021", "allowJs": true, "module": "commonjs", "skipLibCheck": true, "esModuleInterop": true, "noImplicitAny": true, "sourceMap": true, "baseUrl": ".", "outDir": "dist", "moduleResolution": "node", "resolveJsonModule": true, "paths": { "*": ["node_modules/*"], "@/*": ["src/*"], "@main/*": ["src/main/*"], "@shared/*": ["src/shared/*"], "@native/*": ["src/main/native_modules/*"], "@renderer/*": ["src/renderer/*"], "@renderer-lrc/*": ["src/renderer-lrc/*"] } }, "include": ["src/**/*"] }