[
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "> [!CAUTION] \n> PR 请提交到 `dev` 分支  (Please submit PR to `dev` branch)\n\n## PR 解决的问题 (PR Summary)\n> 简单描述一下这个 PR 要解决的问题，如果是 issues 中的问题，请附加 issue 编号  \n> (Please provide a brief description of this PR. If it aims to resolve an issue, please include the issue number)\n\n## 影响范围 (Impact Scope)\n> 可选填写，说明一下改动的影响范围  \n> (Optional, explain the scope of impact of the changes)\n\n## 截屏 (Screenshot)\n| 改动前 (Before) | 改动后 (After) |\n|     ------     |    ------      |\n| 在这里粘贴图片 (Paste screenshot here) | 在这里粘贴图片 (Paste screenshot here) | \n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\n\non:\n  workflow_dispatch:\n\njobs:\n  build-meta:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Generate build metadata\n        run: |\n          VERSION=$(node -p \"require('./package.json').version\")\n          BRANCH=${GITHUB_REF_NAME}\n          COMMIT=${GITHUB_SHA}\n          echo \"{\\\"branch\\\":\\\"${BRANCH}\\\",\\\"version\\\":\\\"${VERSION}\\\",\\\"commit\\\":\\\"${COMMIT}\\\"}\" > build-meta.json\n          cat build-meta.json\n      - uses: actions/upload-artifact@v4\n        with:\n          name: build-meta\n          path: build-meta.json\n          retention-days: 30\n\n  build-windows:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - name: Read version\n        run: |\n          $version = node -p \"require('./package.json').version\"\n          echo \"VERSION=$version\" >> $env:GITHUB_ENV\n      - run: pnpm run package\n      - uses: maotoumao/inno-setup-action-cli@main\n        with:\n          filepath: ./release/build-windows.iss\n          variables: /DMyAppVersion=${{ env.VERSION }} /DMyAppId=${{ secrets.MYAPPID }}\n      - name: Rename setup file\n        run: |\n          Rename-Item -Path \"./out/MusicFreeSetup.exe\" -NewName \"MusicFree-${{ env.VERSION }}-win32-x64-setup.exe\"\n      - name: Generate portable\n        run: |\n          New-Item -ItemType Directory -Path \"./out/MusicFree-win32-x64/portable\" -Force\n      - name: Archive portable\n        run: |\n          Compress-Archive -Path \"./out/MusicFree-win32-x64/*\" -DestinationPath \"./out/MusicFree-${{ env.VERSION }}-win32-x64-portable.zip\"\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-win32-x64-setup\n          path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-setup.exe\n          retention-days: 30\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-win32-x64-portable\n          path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-portable.zip\n          retention-days: 30\n\n  build-windows-legacy:\n    runs-on: windows-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n      - run: pnpm install\n      - name: Read version\n        run: |\n          $version = node -p \"require('./package.json').version\"\n          echo \"VERSION=$version\" >> $env:GITHUB_ENV\n      - name: Override Win7 compatibility files\n        run: |\n          Get-ChildItem -Recurse -Filter \"*.win7.ts\" | ForEach-Object {\n            $target = $_.FullName -replace '\\.win7\\.ts$', '.ts'\n            Write-Host \"Overriding: $target\"\n            Copy-Item $_.FullName $target -Force\n          }\n      - name: Install Electron 22\n        run: pnpm add electron@22 --save-dev\n      - run: pnpm run package\n      - uses: maotoumao/inno-setup-action-cli@main\n        with:\n          filepath: ./release/build-windows.iss\n          variables: /DMyAppVersion=${{ env.VERSION }} /DMyAppId=${{ secrets.MYAPPID }}\n      - name: Rename setup file\n        run: |\n          Rename-Item -Path \"./out/MusicFreeSetup.exe\" -NewName \"MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup.exe\"\n      - name: Generate portable\n        run: |\n          New-Item -ItemType Directory -Path \"./out/MusicFree-win32-x64/portable\" -Force\n      - name: Archive portable\n        run: |\n          Compress-Archive -Path \"./out/MusicFree-win32-x64/*\" -DestinationPath \"./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable.zip\"\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup\n          path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-setup.exe\n          retention-days: 30\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable\n          path: ./out/MusicFree-${{ env.VERSION }}-win32-x64-legacy-portable.zip\n          retention-days: 30\n\n  build-macos-x64:\n    runs-on: macos-13\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - name: Read version\n        run: |\n          VERSION=$(node -p \"require('./package.json').version\")\n          echo \"VERSION=$VERSION\" >> $GITHUB_ENV\n      - run: pnpm run make\n      - name: Rename DMG\n        run: mv \"./out/make/MusicFree-${{ env.VERSION }}-x64.dmg\" \"./out/make/MusicFree-${{ env.VERSION }}-darwin-x64.dmg\"\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-darwin-x64\n          path: ./out/make/MusicFree-${{ env.VERSION }}-darwin-x64.dmg\n          retention-days: 30\n\n  build-macos-arm64:\n    runs-on: macos-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - name: Read version\n        run: |\n          VERSION=$(node -p \"require('./package.json').version\")\n          echo \"VERSION=$VERSION\" >> $GITHUB_ENV\n      - run: pnpm run make -- --arch=arm64\n      - name: Rename DMG\n        run: mv \"./out/make/MusicFree-${{ env.VERSION }}-arm64.dmg\" \"./out/make/MusicFree-${{ env.VERSION }}-darwin-arm64.dmg\"\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-darwin-arm64\n          path: ./out/make/MusicFree-${{ env.VERSION }}-darwin-arm64.dmg\n          retention-days: 30\n\n  build-linux:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install system dependencies\n        run: sudo apt-get update && sudo apt-get install -y rpm\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - name: Read version\n        run: |\n          VERSION=$(node -p \"require('./package.json').version\")\n          echo \"VERSION=$VERSION\" >> $GITHUB_ENV\n      - run: pnpm run make\n      - name: Rename DEB\n        run: |\n          DEB_FILE=$(find ./out/make/deb/x64/ -name \"*.deb\" | head -1)\n          if [ -n \"$DEB_FILE\" ]; then\n            mv \"$DEB_FILE\" \"./out/make/deb/x64/MusicFree-${{ env.VERSION }}-linux-amd64.deb\"\n          fi\n      - name: Rename RPM\n        run: |\n          RPM_FILE=$(find ./out/make/rpm/x64/ -name \"*.rpm\" | head -1)\n          if [ -n \"$RPM_FILE\" ]; then\n            mv \"$RPM_FILE\" \"./out/make/rpm/x64/MusicFree-${{ env.VERSION }}-linux-amd64.rpm\"\n          fi\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-linux-amd64-deb\n          path: ./out/make/deb/x64/MusicFree-${{ env.VERSION }}-linux-amd64.deb\n          retention-days: 30\n      - uses: actions/upload-artifact@v4\n        with:\n          name: MusicFree-${{ env.VERSION }}-linux-amd64-rpm\n          path: ./out/make/rpm/x64/MusicFree-${{ env.VERSION }}-linux-amd64.rpm\n          retention-days: 30\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n    workflow_dispatch:\n        inputs:\n            run_id:\n                description: 'Build workflow run ID to download artifacts from'\n                required: true\n                type: string\n            release_notes:\n                description: 'Release notes (optional, overrides release/version.json changeLog)'\n                required: false\n                type: string\n\npermissions:\n    contents: write\n\njobs:\n    release:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v4\n\n            - name: Download all artifacts from build run\n              env:\n                  GH_TOKEN: ${{ github.token }}\n              run: |\n                  mkdir -p artifacts\n                  gh run download ${{ inputs.run_id }} --dir artifacts\n\n            - name: Read build metadata\n              id: meta\n              run: |\n                  META_FILE=\"artifacts/build-meta/build-meta.json\"\n                  if [ ! -f \"$META_FILE\" ]; then\n                    echo \"::error::build-meta.json not found. Make sure the build workflow completed successfully.\"\n                    exit 1\n                  fi\n                  VERSION=$(jq -r '.version' \"$META_FILE\")\n                  BRANCH=$(jq -r '.branch' \"$META_FILE\")\n                  COMMIT=$(jq -r '.commit' \"$META_FILE\")\n                  echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n                  echo \"branch=$BRANCH\" >> $GITHUB_OUTPUT\n                  echo \"commit=$COMMIT\" >> $GITHUB_OUTPUT\n                  echo \"tag=v$VERSION\" >> $GITHUB_OUTPUT\n                  # Auto-detect prerelease from branch name\n                  if [[ \"$BRANCH\" == *\"dev\"* ]] || [[ \"$BRANCH\" == *\"beta\"* ]] || [[ \"$BRANCH\" == *\"alpha\"* ]]; then\n                    echo \"prerelease=true\" >> $GITHUB_OUTPUT\n                  else\n                    echo \"prerelease=false\" >> $GITHUB_OUTPUT\n                  fi\n\n            - name: Generate release notes\n              id: notes\n              env:\n                  RELEASE_NOTES_INPUT: ${{ inputs.release_notes }}\n              run: |\n                  if [ -n \"$RELEASE_NOTES_INPUT\" ]; then\n                    printf '%s\\n' \"$RELEASE_NOTES_INPUT\" > release-notes.md\n                  else\n                    jq -r '.changeLog | join(\"\\n\")' release/version.json > release-notes.md\n                  fi\n                  echo \"=== Release notes ===\"\n                  cat release-notes.md\n\n            - name: Collect release assets\n              run: |\n                  mkdir -p release-assets\n                  find artifacts -type f \\( -name \"*.exe\" -o -name \"*.zip\" -o -name \"*.dmg\" -o -name \"*.deb\" -o -name \"*.rpm\" \\) -exec cp {} release-assets/ \\;\n                  echo \"=== Release assets ===\"\n                  ls -lh release-assets/\n\n            - name: Create tag\n              env:\n                  GH_TOKEN: ${{ github.token }}\n              run: |\n                  TAG=\"${{ steps.meta.outputs.tag }}\"\n                  COMMIT=\"${{ steps.meta.outputs.commit }}\"\n                  # Check if tag already exists\n                  if gh api \"repos/${{ github.repository }}/git/refs/tags/${TAG}\" &>/dev/null; then\n                    echo \"::error::Tag ${TAG} already exists. Aborting.\"\n                    exit 1\n                  fi\n                  gh api \"repos/${{ github.repository }}/git/refs\" \\\n                    -X POST \\\n                    -f ref=\"refs/tags/${TAG}\" \\\n                    -f sha=\"${COMMIT}\"\n\n            - name: Create GitHub Draft Release\n              env:\n                  GH_TOKEN: ${{ github.token }}\n              run: |\n                  gh release create \"${{ steps.meta.outputs.tag }}\" \\\n                    --title \"MusicFree ${{ steps.meta.outputs.version }}\" \\\n                    --notes-file release-notes.md \\\n                    --draft \\\n                    ${{ steps.meta.outputs.prerelease == 'true' && '--prerelease' || '' }} \\\n                    release-assets/*\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n.DS_Store\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n\n# next.js build output\n.next\n\n# nuxt.js build output\n.nuxt\n\n# vuepress build output\n.vuepress/dist\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# Webpack\n.webpack/\n\n# Vite\n.vite/\n\n# Electron-Forge\nout/\n\ntmp\n\nplugins\n\n.VSCodeCounter\n\n.idea/\n\nundefinedelectron-log-preload.js\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpm run lint-staged\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"files.associations\": {\n    \"*.html\": \"html\",\n    \"map\": \"cpp\"\n  },\n  \"editor.defaultFormatter\": \"dbaeumer.vscode-eslint\",\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"vscode.typescript-language-features\"\n  },\n  \"eslint.format.enable\": true\n}"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "# MusicFree 桌面版\n![GitHub Repo stars](https://img.shields.io/github/stars/maotoumao/MusicFreeDesktop) \n![GitHub forks](https://img.shields.io/github/forks/maotoumao/MusicFreeDesktop)\n![star](https://gitcode.com/maotoumao/MusicFreeDesktop/star/badge.svg)\n\n![GitHub License](https://img.shields.io/github/license/maotoumao/MusicFreeDesktop)\n![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/maotoumao/MusicFreeDesktop/total)\n![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/maotoumao/MusicFreeDesktop)\n![GitHub package.json version](https://img.shields.io/github/package-json/v/maotoumao/MusicFreeDesktop)\n\n<a href=\"https://trendshift.io/repositories/3961\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/3961\" alt=\"maotoumao%2FMusicFreeDesktop | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n---\n\n## 项目使用约定：\n本项目基于 AGPL 3.0 协议开源，使用此项目时请遵守开源协议。  \n除此外，希望你在使用代码时已经了解以下额外说明：\n\n1. 打包、二次分发 **请保留代码出处**：https://github.com/maotoumao/MusicFree\n2. 请不要用于商业用途，合法合规使用代码；\n3. 如果开源协议变更，将在此 Github 仓库更新，不另行通知。\n---\n\n## 简介\n\n一个插件化、定制化、无广告的免费音乐播放器。\n> 当前版本支持 Windows 和 macOS 和 Linux\n\n<img src=\"./src/assets/imgs/wechat_channel1.png\" height=\"144px\" title=\"微信公众号\" style=\"display:inherit;\"/>\n\n\n### 下载地址\n\n[飞书云文档](https://r0rvr854dd1.feishu.cn/drive/folder/IrVEfD67KlWZGkdqwjecLHFNnBb?from=from_copylink)\n\n## 特性\n\n- 插件化：本软件仅仅是一个播放器，本身**并不集成**任何平台的任何音源，所有的搜索、播放、歌单导入等功能全部基于**插件**。这也就意味着，**只要可以在互联网上搜索到的音源，只要有对应的插件，你都可以使用本软件进行搜索、播放等功能。** 关于插件的详细说明请参考 [安卓版 Readme 的插件部分](https://github.com/maotoumao/MusicFree#%E6%8F%92%E4%BB%B6)。\n\n- 插件支持的功能：搜索（音乐、专辑、作者、歌单）、播放、查看专辑、查看作者详细信息、导入单曲、导入歌单、获取歌词等。\n\n- 定制化：本软件可以通过主题包定义软件外观及背景，详见下方主题包一节。\n\n- 无广告：基于 AGPL3.0 协议开源，将会保持免费。\n\n- 隐私：软件所有数据存储在本地，本软件不会上传你的个人信息。\n\n## 插件\n\n插件协议和安卓版完全相同。\n\n[示例插件仓库](https://github.com/maotoumao/MusicFreePlugins)，你可以根据[插件开发文档](https://musicfree.catcat.work/plugin/introduction.html) 开发适配于任意音源的插件。\n\n## 主题包\n\n主题包是一个文件夹，文件夹内必须包含两个文件：\n\n```bash\nindex.css\nconfig.json\n```\n\n### index.css\n\nindex.css 中可以覆盖界面中的任何样式。你可以通过定义 css 变量来完成大部分颜色的替换，也可以查看源代码，根据类名等覆盖样式。\n\n支持的 css 变量如下：\n\n``` css\n:root {\n  --primaryColor: #f17d34; // 主色调\n  --backgroundColor: #fdfdfd; // 背景色\n  --dividerColor: rgba(0, 0, 0, 0.1); // 分割线颜色\n  --listHoverColor: rgba(0, 0, 0, 0.05); // 列表悬浮颜色\n  --listActiveColor: rgba(0, 0, 0, 0.1); // 列表选中颜色\n  --textColor: #333333; // 主文本颜色\n  --maskColor: rgba(51, 51, 51, 0.2); // 遮罩层颜色\n  --shadowColor: rgba(0, 0, 0, 0.2); // 对话框等阴影颜色\n  /** --shadow:  // shadow属性 */\n  --placeholderColor: #f4f4f4; // 输入区背景颜色\n  --successColor: #08A34C; // 成功颜色\n  --dangerColor: #FC5F5F; // 危险颜色\n  --infoColor: #0A95C8; // 通知颜色\n  --headerTextColor: white; // 顶部文本颜色\n}\n```\n\n具体的例子可以参考 [暗黑模式](https://github.com/maotoumao/MusicFreeThemePacks/blob/master/darkmode/index.css)\n\n除了通过 css 定义常规样式外，也可以通过在 config.json 中定义 iframes 字段，用来把任意的 html 文件作为软件背景，这样可以实现一些单纯用 css 无法实现的效果。\n\n### config.json\n\nconfig.json 是一个配置文件。\n\n```json\n{\n    \"name\": \"主题包的名称\",\n    \"preview\": \"#000000\", // 预览图，支持颜色或图片；\n    \"description\": \"描述文本\",\n    \"iframes\": {\n        \"app\": \"http://musicfree.catcat.work\", // 整个软件的背景\n        \"header\": \"\", // 头部区域的背景\n        \"body\": \"\", // 侧边栏+主页面区域的背景\n        \"side-bar\": \"\", // 侧边栏区域的背景\n        \"page\": \"\", // 主页面区域的背景\n        \"music-bar\": \"\", // 底部音乐栏的背景\n\n    }\n}\n```\n\n如果需要指向本地的图片，可以通过 ```@/``` 表示主题包的路径；preview、iframes、以及 iframes 指向的 html 文件都会把 ```@/``` 替换为 ```主题包路径```。详情可参考 [樱花主题](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/sakura)\n\n### 主题包示例\n\n示例仓库：https://github.com/maotoumao/MusicFreeThemePacks\n\n几个主题包效果截图：\n\n#### 暗黑模式\n[源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/darkmode)\n\n![暗黑模式](./.imgs/darkmode.png)\n\n#### 背景图片\n[源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/night-star)\n\n![背景图片](./.imgs/night-star.png)\n\n#### fliqlo\n[源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/fliqlo)\n\n![fliqlo](./.imgs/fliqlo.gif)\n\n#### 樱花\n[源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/sakura)\n\n![樱花](./.imgs/sakura.gif)\n\n#### 雨季\n[源代码](https://github.com/maotoumao/MusicFreeThemePacks/tree/master/rainy-season)\n\n![雨季](./.imgs/rainy-season.gif)\n\n## 启动项目\n\n下载仓库代码之后，在根目录下执行：\n\n```bash\nnpm install\nnpm start\n```\n\n## 支持这个项目\n\n如果你喜欢这个项目，或者希望我可以持续维护下去，你可以通过以下任何一种方式支持我;)\n\n1. Star 这个项目，分享给你身边的人；\n2. 关注公众号【一只猫头猫】获取最新信息；\n\n<img src=\"./src/assets/imgs/wechat_channel.jpg\" height=\"160px\" title=\"微信公众号\" style=\"display:inherit;\"/>\n\n## 截图\n\n![screenshot](./.imgs/screenshot.png)\n\n![screenshot](./.imgs/screenshot1.png)\n\n![screenshot](./.imgs/screenshot2.png)\n"
  },
  {
    "path": "changelog.md",
    "content": "`2025-10-24 v0.0.8`\n1. 【修复】修复了一些可能导致白屏的问题\n\n`2025-03-30 v0.0.7`\n1. 【功能】开发者模式：狂点托盘图标可打开开发者工具\n2. 【修复】修复退出应用时可能出现的进程残留的问题（感谢@dyfllll）\n3. 【修复】修复windows控制中心无法控制暂停/播放状态的问题\n4. 【修复】修复打开歌词窗口/迷你模式窗口歌词可能始终为空的问题\n5. 【修复】修复配置出错时软件白屏的问题\n6. 【修复】修复任务栏上的关闭按钮表现和设置中的选项不一致的问题\n\n`2024-12-25 v0.0.6`\n1. 【优化】大量代码重构\n2. 【功能】新增播放失败时不寻找其他音质版本的配置\n3. 【功能】歌单内支持通过 ctrl 键盘多选歌曲批量操作\n4. 【功能】支持自定义主窗口大小\n5. 【功能】支持自定义歌词窗口大小\n6. 【功能】调整播放详情页面的样式\n7. 【功能】支持了评论功能（需要插件支持）\n8. 【修复】修复了歌单id无法带特殊字符的问题\n9. 【修复】修复了音源太多时布局异常的问题\n\n`2024-06.25 v0.0.5`\n【修复】修复重启软件后歌单丢失的问题；如果未出现上述问题可忽略此版本更新\n\n`2024-06.16 v0.0.4`\n\n1. 【功能】播放列表支持拖拽排序\n2. 【功能】支持多语言。本次支持简体中文、繁体中文、英文、西班牙语\n3. 【功能】支持歌词翻译功能（需要插件实现 getLyric 方法）\n4. 【功能】新增最近播放，默认保存最近播放的 500 首歌\n5. 【功能】新增小窗模式\n6. 【功能】新增音频设备移除时的行为设置，现在可以让拔掉耳机的时候停止播放了\n7. 【功能】新增单独的主题页，可以在主题市场中直接使用主题；本地.mftheme 主题可以直接拖拽到播放器安装\n8. 【优化】本地音乐会尝试读取本地路径下的同名 .lrc 文件作为歌词；同时会读取同名 -tr.lrc 文件作为翻译\n9. 【修复】修复部分情况下本地歌词无法读取的问题\n10. 【修复】修复 linux 托盘点击无效的问题\n\n`2023-12.23 v0.0.3`\n\n1. 【功能】插件支持拖拽排序，该排序会影响到搜索结果、排行榜、热门歌单的展示顺序\n2. 【功能】播放列表支持多选快捷键(Ctrl + A 全选、按住 Shift 批量选择)\n3. 【功能】本地音乐新增搜索功能\n4. 【功能】歌单内歌曲支持拖拽排序\n5. 【功能】新增了一些插件设置，比如启动软件时自动更新插件\n6. 【功能】新增缓存设置，可以在设置中清空软件缓存\n7. 【功能】新增网络代理设置\n8. 【功能】插件协议更新：新增支持「用户变量」。\n9. 【功能】插件协议更新：榜单列表支持分页\n10. 【功能】歌单支持 WebDAV 备份；插件预置的 npm 包新增 webdav，配合 WebDAV 插件即可播放 WebDAV 源\n11. 【优化】排序/过滤后，点击歌单列表会播放排序/过滤后的歌曲，而非全部歌曲\n12. 【优化】优化批量删除歌曲失败时的表现\n13. 【优化】优化歌单内歌曲较多时的体验\n14. 【优化】优化歌曲名称超长时右下角菜单的显示\n15. 【优化】加载本地歌曲时会自动识别歌曲元信息的编码，减少出现乱码的可能性\n16. 【优化】优化本地歌曲过多时的拖拽表现\n17. 【优化】优化 windows7/8 部分按钮的表现\n18. 【修复】修复 macos、linux 本地歌曲无法播放的问题\n19. 【修复】修复部分情况下无法右键打开下载文件夹的问题\n20. 【修复】修复 macos 输入框无法粘贴的问题\n21. 【修复】修复部分情况下快捷键无法删除的问题\n22. 【修复】重写了本地歌单逻辑，修复收藏歌单部分情况下无法点击的问题\n\n`2023-11.5 v0.0.2`\n\n1. 【功能】支持播放 .m3u8 源\n2. 【功能】打开播放列表时锚定到当前正在播放的歌曲\n3. 【功能】新增搜索歌词功能，你可以在歌曲详情页右键点击，并单击【搜索歌词】功能唤起搜索弹窗\n4. 【功能】重写了本地歌曲的导入机制，新增本地音乐视图（列表、作者、专辑、文件夹）\n5. 【功能】新增支持隐藏歌曲列表的部分列\n6. 【功能】新增快捷键：喜欢/不喜欢歌曲\n7. 【功能】已经下载的歌曲/本地歌曲支持右键打开\n8. 【功能, windows】新增缩略图配置，你可以选择在任务栏悬浮图标时展示原窗口或专辑封面\n9. 【功能, windows】新增任务栏播放控制按钮\n10. 【优化】优化主题包安装机制：取消原本安装文件夹的机制，修改为安装 .mftheme 或 .zip 的文件，支持批量安装\n11. 【优化】优化了从热门歌单页详情页返回时的表现\n12. 【优化】优化了歌曲详情页右键按钮的表现\n13. 【优化】优化了主窗口和歌词窗口的通信机制\n14. 【修复】修复作者页歌曲显示不全的问题\n15. 【修复】修复包含特殊字符时下载失败的问题\n16. 【修复, macos】修复 macos 图标显示异常的问题\n17. 【修复, linux】修复 linux 无法最小化的问题\n18. 【打包】新增 windows 免安装版、mac m1/m2 版、linux 版\n19. 【版本号】桌面版后缀取消 -alpha 后缀，以正式版本号发布。\n\n`2023.9.5 v0.0.1-alpha.0`\n\n1. 【功能】新增搜索历史记录\n2. 【功能】新增下载歌词、调整歌词字体大小功能\n3. 【功能】桌面歌词支持自定义字体\n4. 【功能】支持选择音频输出设备\n5. 【功能】下载歌曲功能\n6. 【功能】设置中新增快捷键配置\n7. 【功能】支持 windows8.1 及以下系统（下载链接中的 windows-legacy-setup.exe，win10/11 下载哪个 exe 都行）\n8. 【优化】调整左下角歌曲信息的可响应区域\n9. 【修复】修复本地歌曲只显示 100 首的问题\n10. 【修复】修复 mac 系统无法移动桌面歌词的问题\n11. 【修复】修复本地插件安装失败的问题\n"
  },
  {
    "path": "config/webpack.main.config.ts",
    "content": "import type { Configuration } from \"webpack\";\nimport path from \"path\";\n\nimport { rules } from \"./webpack.rules\";\n\nexport const mainConfig: Configuration = {\n  /**\n   * This is the main entry point for your application, it's the first file\n   * that runs in the main process.\n   */\n  entry: {\n    index: \"./src/main/index.ts\",\n  },\n  // Put your normal webpack config below here\n  module: {\n    rules,\n  },\n  resolve: {\n    extensions: [\".js\", \".ts\", \".jsx\", \".tsx\", \".css\", \".json\", '.node'],\n    alias: {\n      \"@\": path.join(__dirname, \"../src\"),\n      \"@main\": path.join(__dirname, \"../src/main\"),\n      \"@native\": path.join(__dirname, \"../src/main/native_modules\"),\n      \"@shared\": path.join(__dirname, \"../src/shared\")\n    },\n  },\n  output: {\n    filename: \"[name].js\",\n  },\n  externals: ['sharp']\n};\n"
  },
  {
    "path": "config/webpack.plugins.ts",
    "content": "import type IForkTsCheckerWebpackPlugin from \"fork-ts-checker-webpack-plugin\";\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst ForkTsCheckerWebpackPlugin: typeof IForkTsCheckerWebpackPlugin = require(\"fork-ts-checker-webpack-plugin\");\nconst relocateLoader = require(\"@vercel/webpack-asset-relocator-loader\");\n\nexport const plugins = [\n  new ForkTsCheckerWebpackPlugin({\n    logger: \"webpack-infrastructure\",\n  }),\n  {\n    apply(compiler: any) {\n      compiler.hooks.compilation.tap(\n        \"webpack-asset-relocator-loader\",\n        (compilation: any) => {\n          relocateLoader.initAssetCache(compilation, \"native_modules\");\n        }\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "config/webpack.renderer.config.ts",
    "content": "import type { Configuration } from \"webpack\";\nimport path from \"path\";\n\nimport { rules } from \"./webpack.rules\";\nimport { plugins } from \"./webpack.plugins\";\n\nrules.push(\n  {\n    test: /\\.css$/,\n    use: [{ loader: \"style-loader\" }, { loader: \"css-loader\" }],\n  },\n  {\n    test: /\\.scss$/,\n    use: [\n      { loader: \"style-loader\" },\n      { loader: \"css-loader\" },\n      { loader: \"sass-loader\" },\n    ],\n  },\n  {\n    test: /\\.(woff|woff2|eot|ttf|otf)$/i,\n    type: \"asset/resource\",\n  },\n  {\n    test: /\\.(png|jpg|jpeg|gif)$/i,\n    type: \"asset/resource\",\n  },\n  {\n    test: /\\.svg$/,\n    use: [\n      {\n        loader: \"@svgr/webpack\",\n        options: {\n          prettier: false,\n          svgo: false,\n          svgoConfig: {\n            plugins: [{ removeViewBox: false }],\n          },\n          titleProp: true,\n          ref: true,\n        },\n      },\n    ],\n  }\n);\n\nexport const rendererConfig: Configuration = {\n  module: {\n    rules,\n  },\n  plugins,\n  resolve: {\n    extensions: [\".js\", \".ts\", \".jsx\", \".tsx\", \".css\", \".scss\"],\n    alias: {\n      \"@\": path.join(__dirname, \"../src\"),\n      \"@renderer\": path.join(__dirname, \"../src/renderer\"),\n      \"@renderer-lrc\": path.join(__dirname, \"../src/renderer-lrc\"),\n      \"@shared\": path.join(__dirname, \"../src/shared\")\n    },\n  },\n  externals: process.platform !== \"darwin\" ? [\"fsevents\"] : undefined,\n};\n"
  },
  {
    "path": "config/webpack.rules.ts",
    "content": "import type { ModuleOptions } from 'webpack';\n\nexport const rules: Required<ModuleOptions>['rules'] = [\n  // Add support for native node modules\n  {\n    // We're specifying native_modules in the test because the asset relocator loader generates a\n    // \"fake\" .node file which is really a cjs file.\n    test: /native_modules[/\\\\].+\\.node$/,\n    use: 'node-loader',\n  },\n  {\n    test: /[/\\\\]node_modules[/\\\\].+\\.(m?js|node)$/,\n    parser: { amd: false },\n    use: {\n      loader: '@vercel/webpack-asset-relocator-loader',\n      options: {\n        outputAssetBase: 'native_modules',\n      },\n    },\n  },\n  {\n    test: /\\.tsx?$/,\n    exclude: /(node_modules|\\.webpack)/,\n    use: {\n      loader: 'ts-loader',\n      options: {\n        transpileOnly: true,\n      },\n    },\n  },\n  {\n    test: /\\.jsx?$/,\n    use: {\n      loader: 'babel-loader',\n      options: {\n        exclude: /node_modules/,\n        presets: ['@babel/preset-react']\n      }\n    }\n  },\n];\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import globals from \"globals\";\nimport js from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\nimport importPlugin from \"eslint-plugin-import\";\nimport stylistic from \"@stylistic/eslint-plugin\";\n\nexport default [\n    // JavaScript 推荐配置\n    js.configs.recommended,\n\n    // TypeScript 推荐配置\n    ...tseslint.configs.recommended,\n\n    // 全局配置\n    {\n        languageOptions: {\n            globals: {\n                ...globals.browser,\n                ...globals.node,\n                ...globals.es6,\n            },\n        },\n    },    // TypeScript 和 JavaScript 文件配置\n    {\n        files: [\"**/*.{js,mjs,cjs,ts,tsx}\"],\n        plugins: {\n            import: importPlugin,\n            \"@stylistic\": stylistic,\n        },\n        rules: {\n            // 保持原有的规则配置\n            \"@typescript-eslint/ban-ts-comment\": \"off\",\n            \"@typescript-eslint/no-var-requires\": \"warn\",\n            \"import/no-unresolved\": \"off\",\n            \"@typescript-eslint/no-empty-interface\": \"off\",\n            \"@typescript-eslint/no-explicit-any\": \"off\",\n            \"@typescript-eslint/no-empty-function\": \"warn\",\n            \"no-empty\": \"warn\",\n            \"no-useless-catch\": \"warn\",\n            \"prefer-const\": \"warn\",\n            // 样式规则迁移到 ESLint Stylistic\n            \"@stylistic/quotes\": [\"warn\", \"double\"],\n            \"@stylistic/object-curly-spacing\": [\"error\", \"always\"],\n            \"@stylistic/indent\": [\"error\", 4], // 统一缩进\n            \"@stylistic/semi\": [\"error\", \"always\"], // 强制分号\n            \"@stylistic/comma-dangle\": [\"error\", \"always-multiline\"], // 多行末尾逗号\n            \"@stylistic/brace-style\": [\"error\", \"1tbs\"], // 大括号风格\n\n            // Import 相关规则\n            \"import/no-duplicates\": \"error\",\n            \"import/no-self-import\": \"error\",\n            \"import/no-useless-path-segments\": \"error\",            // 企业级最佳实践\n            \"@typescript-eslint/no-unused-vars\": [\"warn\", {\n                \"argsIgnorePattern\": \"^_\",\n                \"varsIgnorePattern\": \"^_\",\n            }],\n            \"@typescript-eslint/no-non-null-assertion\": \"warn\",\n            \"no-console\": \"warn\",\n        },\n        settings: {\n            \"import/resolver\": {\n                \"typescript\": {\n                    \"alwaysTryTypes\": true,\n                    \"project\": \"./tsconfig.json\",\n                },\n                \"node\": {\n                    \"extensions\": [\".js\", \".jsx\", \".ts\", \".tsx\"],\n                },\n            },\n        },\n    },\n\n    // 特定于主进程的配置\n    {\n        files: [\"src/main/**/*.{ts,js}\"],\n        languageOptions: {\n            globals: {\n                ...globals.node,\n            },\n        },\n        rules: {\n            \"no-console\": \"off\", // 主进程允许使用 console\n        },\n    },\n\n    // 特定于渲染进程的配置  \n    {\n        files: [\"src/renderer*/**/*.{ts,tsx,js,jsx}\"],\n        languageOptions: {\n            globals: {\n                ...globals.browser,\n            },\n        },\n    },\n\n    // 配置文件和脚本的特殊规则\n    {\n        files: [\"*.config.{js,ts,mjs}\", \"scripts/**/*.{js,ts}\"],\n        rules: {\n            \"@typescript-eslint/no-var-requires\": \"off\",\n            \"no-console\": \"off\",\n        },\n    },\n\n    // 忽略文件\n    {\n        ignores: [\n            \"node_modules/**\",\n            \"dist/**\",\n            \".webpack/**\",\n            \"out/**\",\n            \"release/**\",\n            \"**/*.d.ts\",\n        ],\n    },\n];\n"
  },
  {
    "path": "forge.config.ts",
    "content": "import type { ForgeConfig } from \"@electron-forge/shared-types\";\nimport { MakerZIP } from \"@electron-forge/maker-zip\";\nimport { MakerDeb } from \"@electron-forge/maker-deb\";\nimport { MakerDMG } from \"@electron-forge/maker-dmg\";\nimport { WebpackPlugin } from \"@electron-forge/plugin-webpack\";\n\nimport { mainConfig } from \"./config/webpack.main.config\";\nimport { rendererConfig } from \"./config/webpack.renderer.config\";\nimport path from \"path\";\n\nconst config: ForgeConfig = {\n  packagerConfig: {\n    appBundleId: \"fun.upup.musicfree\",\n    icon: path.resolve(__dirname, \"res/logo\"),\n    executableName: \"MusicFree\",\n    extraResource: [path.resolve(__dirname, \"res\")],\n    protocols: [\n      {\n        name: \"MusicFree\",\n        schemes: [\"musicfree\"],\n      },\n    ],\n  },\n  rebuildConfig: {},\n  makers: [\n    // new MakerSquirrel({\n    //   exe: \"MusicFree\",\n    //   setupIcon: path.resolve(__dirname, \"resources/logo.ico\"),\n    //   setupMsi: \"MusicFreeInstaller\",\n    // }),\n    new MakerZIP({}, [\"darwin\"]),\n    new MakerDMG(\n      {\n        // background\n        format: \"ULFO\",\n      },\n      [\"darwin\"]\n    ),\n    // new MakerRpm({}),\n    new MakerDeb({\n      options: {\n        name: \"MusicFree\",\n        bin: \"MusicFree\",\n        mimeType: [\"x-scheme-handler/musicfree\"],\n      },\n    }),\n  ],\n  plugins: [\n    new WebpackPlugin({\n      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:;`,\n      mainConfig,\n      renderer: {\n        config: rendererConfig,\n        entryPoints: [\n          {\n            html: \"./src/renderer/document/index.html\",\n            js: \"./src/renderer/document/index.tsx\",\n            name: \"main_window\",\n            preload: {\n              js: \"./src/preload/index.ts\",\n            },\n          },\n          {\n            html: \"./src/renderer-lrc/document/index.html\",\n            js: \"./src/renderer-lrc/document/index.tsx\",\n            name: \"lrc_window\",\n            preload: {\n              js: \"./src/preload/extension.ts\",\n            },\n          },\n          {\n            html: \"./src/renderer-minimode/document/index.html\",\n            js: \"./src/renderer-minimode/document/index.tsx\",\n            name: \"minimode_window\",\n            preload: {\n              js: \"./src/preload/extension.ts\",\n            },\n          },\n          /** webworkers */\n          {\n            js: \"./src/webworkers/downloader.ts\",\n            name: \"worker_downloader\",\n            nodeIntegration: true,\n          },\n          {\n            js: \"./src/webworkers/local-file-watcher.ts\",\n            name: \"local_file_watcher\",\n            nodeIntegration: true,\n          },\n          {\n            js: \"./src/webworkers/db-worker.ts\",\n            name: \"db\",\n            nodeIntegration: true,\n          }\n        ],\n      },\n    }),\n    {\n      name: \"@timfish/forge-externals-plugin\",\n      config: {\n        externals: [\"sharp\"],\n        includeDeps: true,\n      },\n    },\n  ],\n};\n\nexport default config;\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"musicfree-desktop\",\n  \"productName\": \"MusicFree\",\n  \"version\": \"0.0.8\",\n  \"description\": \"一个插件化的音乐播放器\",\n  \"main\": \".webpack/main\",\n  \"scripts\": {\n    \"start\": \"electron-forge start\",\n    \"dev\": \"electron-forge start --inspect-electron\",\n    \"package\": \"electron-forge package\",\n    \"make\": \"electron-forge make\",\n    \"publish\": \"electron-forge publish\",\n    \"lint\": \"eslint ./src --fix\",\n    \"lint-staged\": \"lint-staged\",\n    \"prepare\": \"husky install\"\n  },\n  \"keywords\": [],\n  \"author\": {\n    \"name\": \"猫头猫\",\n    \"email\": \"lhx_xjtu@163.com\"\n  },\n  \"license\": \"GPL\",\n  \"lint-staged\": {\n    \"src/**/*.{ts,tsx,js}\": [\n      \"npm run lint\",\n      \"git add .\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.22.1\",\n    \"@babel/preset-react\": \"^7.22.0\",\n    \"@electron-forge/cli\": \"6.4.1\",\n    \"@electron-forge/maker-deb\": \"6.4.1\",\n    \"@electron-forge/maker-dmg\": \"6.4.1\",\n    \"@electron-forge/maker-rpm\": \"6.4.1\",\n    \"@electron-forge/maker-squirrel\": \"6.4.1\",\n    \"@electron-forge/maker-zip\": \"6.4.1\",\n    \"@electron-forge/plugin-webpack\": \"6.4.1\",\n    \"@eslint/js\": \"^9.15.0\",\n    \"@larksuiteoapi/node-sdk\": \"^1.50.1\",\n    \"@stylistic/eslint-plugin\": \"^5.0.0\",\n    \"@svgr/webpack\": \"^8.1.0\",\n    \"@timfish/forge-externals-plugin\": \"^0.2.1\",\n    \"@types/better-sqlite3\": \"^7.6.13\",\n    \"@types/crypto-js\": \"^4.2.2\",\n    \"@types/he\": \"^1.2.3\",\n    \"@types/lodash.debounce\": \"^4.0.9\",\n    \"@types/lodash.shuffle\": \"^4.2.9\",\n    \"@types/lodash.throttle\": \"^4.1.9\",\n    \"@types/object-path\": \"^0.11.4\",\n    \"@types/react\": \"^18.3.2\",\n    \"@types/react-dom\": \"^18.3.0\",\n    \"@types/unzipper\": \"^0.10.9\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.15.0\",\n    \"@typescript-eslint/parser\": \"^8.15.0\",\n    \"@vercel/webpack-asset-relocator-loader\": \"^1.7.3\",\n    \"babel-loader\": \"^9.1.3\",\n    \"cross-env\": \"^7.0.3\",\n    \"css-loader\": \"^6.11.0\",\n    \"electron\": \"^25.3.0\",\n    \"eslint\": \"^9.15.0\",\n    \"eslint-import-resolver-typescript\": \"^3.6.3\",\n    \"eslint-plugin-import\": \"^2.31.0\",\n    \"file-loader\": \"^6.2.0\",\n    \"fork-ts-checker-webpack-plugin\": \"^7.3.0\",\n    \"globals\": \"^15.12.0\",\n    \"husky\": \"^9.0.11\",\n    \"lint-staged\": \"^15.2.2\",\n    \"node-loader\": \"^2.0.0\",\n    \"sass\": \"^1.83.0\",\n    \"sass-loader\": \"^16.0.4\",\n    \"style-loader\": \"^4.0.0\",\n    \"ts-loader\": \"^9.5.1\",\n    \"ts-node\": \"^10.9.2\",\n    \"typescript\": \"~5.0.4\",\n    \"typescript-eslint\": \"^8.15.0\"\n  },\n  \"dependencies\": {\n    \"@headlessui/react\": \"^1.7.15\",\n    \"@tanstack/react-table\": \"^8.17.3\",\n    \"animate.css\": \"^4.1.1\",\n    \"axios\": \"1.7.4\",\n    \"better-sqlite3\": \"^12.1.1\",\n    \"big-integer\": \"^1.6.52\",\n    \"cheerio\": \"^1.0.0-rc.12\",\n    \"chokidar\": \"^3.6.0\",\n    \"color\": \"^4.2.3\",\n    \"comlink\": \"^4.4.2\",\n    \"compare-versions\": \"^6.1.0\",\n    \"crypto-js\": \"^4.2.0\",\n    \"dayjs\": \"^1.11.11\",\n    \"dexie\": \"^3.2.4\",\n    \"electron-log\": \"^5.2.0\",\n    \"eventemitter3\": \"^5.0.1\",\n    \"he\": \"^1.2.0\",\n    \"hls.js\": \"^1.5.8\",\n    \"hotkeys-js\": \"^3.13.7\",\n    \"https-proxy-agent\": \"^7.0.4\",\n    \"i18next\": \"^22.5.1\",\n    \"iconv-lite\": \"^0.6.3\",\n    \"immer\": \"^10.1.1\",\n    \"jschardet\": \"^3.1.3\",\n    \"lodash.shuffle\": \"^4.2.0\",\n    \"lodash.throttle\": \"^4.1.1\",\n    \"lru-cache\": \"^10.2.2\",\n    \"music-metadata\": \"^8.3.0\",\n    \"nanoid\": \"^4.0.2\",\n    \"object-path\": \"^0.11.8\",\n    \"p-queue\": \"^7.4.1\",\n    \"qs\": \"^6.12.1\",\n    \"rc-slider\": \"^10.6.2\",\n    \"react\": \"^18.3.1\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-error-boundary\": \"^5.0.0\",\n    \"react-i18next\": \"^12.3.1\",\n    \"react-router-dom\": \"^6.23.1\",\n    \"react-toastify\": \"^9.1.3\",\n    \"react-tooltip\": \"^5.26.4\",\n    \"rimraf\": \"^5.0.7\",\n    \"sharp\": \"^0.32.6\",\n    \"socket.io\": \"^4.7.5\",\n    \"unzipper\": \"^0.11.6\",\n    \"webdav\": \"^5.6.0\"\n  }\n}\n"
  },
  {
    "path": "release/build-windows.iss",
    "content": "; Script generated by the Inno Setup Script Wizard.\n; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!\n\n#define MyAppName \"MusicFree\"\n#ifndef MyAppVersion\n#define MyAppVersion \"0.0.0-alpha.0\"\n#endif\n#define MyAppPublisher \"maotoumao\"\n#define MyAppURL \"https://musicfree.catcat.work\"\n#define MyAppExeName \"MusicFree.exe\"\n#ifndef MyAppId\n#define MyAppId\n#endif\n\n\n[Setup]\n; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.\n; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)\nAppId={{{#MyAppId}}\nAppName={#MyAppName}\nAppVersion={#MyAppVersion}\n;AppVerName={#MyAppName} {#MyAppVersion}\nAppPublisher={#MyAppPublisher}\nAppPublisherURL={#MyAppURL}\nAppSupportURL={#MyAppURL}\nAppUpdatesURL={#MyAppURL}\nDefaultDirName={autopf}\\{#MyAppName}\nDisableProgramGroupPage=yes\n; Uncomment the following line to run in non administrative install mode (install for current user only.)\n;PrivilegesRequired=lowest\nPrivilegesRequiredOverridesAllowed=dialog\nOutputDir=..\\out\nOutputBaseFilename=MusicFreeSetup\nSetupIconFile=..\\res\\logo.ico\nCompression=lzma\nSolidCompression=yes\nWizardStyle=modern\n\n[Languages]\nName: \"english\"; MessagesFile: \"compiler:Default.isl\"\nName: \"chinesesimplified\"; MessagesFile: \"compiler:Languages\\ChineseSimplified.isl\"\n\n[Tasks]\nName: \"desktopicon\"; Description: \"{cm:CreateDesktopIcon}\"; GroupDescription: \"{cm:AdditionalIcons}\"; Flags: unchecked\n\n[Files]\nSource: \"..\\out\\MusicFree-win32-x64\\{#MyAppExeName}\"; DestDir: \"{app}\"; Flags: ignoreversion\nSource: \"..\\out\\MusicFree-win32-x64\\*\"; DestDir: \"{app}\"; Flags: ignoreversion recursesubdirs createallsubdirs\n; NOTE: Don't use \"Flags: ignoreversion\" on any shared system files\n\n[Icons]\nName: \"{autoprograms}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"\nName: \"{autodesktop}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"; Tasks: desktopicon\n\n[Run]\nFilename: \"{app}\\{#MyAppExeName}\"; Description: \"{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}\"; Flags: nowait postinstall skipifsilent\n\n"
  },
  {
    "path": "release/version.json",
    "content": "{\n  \"version\": \"0.0.8\",\n  \"changeLog\": [\n    \"1. 【修复】修复了一些可能导致白屏的问题\"\n  ],\n  \"download\": [\n    \"https://r0rvr854dd1.feishu.cn/drive/folder/IrVEfD67KlWZGkdqwjecLHFNnBb?from=from_copylink\"\n  ]\n}"
  },
  {
    "path": "res/.service/request-forwarder.js",
    "content": "const http = require(\"http\");\nconst https = require(\"https\");\n\n\nconst defaultPort = 52735;\nconst maxRetries = 20;\n\nlet retryCount = 0;\n\nfunction forwardRequest(clientRes, url, method, headers) {\n\n    // 确保 host 正确\n    let host = headers?.host;\n\n    if (!host || host.includes(\"localhost\") || host.includes(\"127.0.0.1\")) {\n        // 如果没有提供 host，且是本地请求，则使用目标 URL 的主机名\n        host = new URL(url).host;\n    }\n\n    const options = {\n        method: method,\n        headers: {\n            ...(headers || {}),\n            host, // 确保目标主机名正确\n        },\n    };\n\n    const protocol = url.startsWith(\"https\") ? https : http;\n\n    const req = protocol.request(url, options, (targetRes) => {\n        // 将目标响应的状态码和头部转发到客户端\n        clientRes.writeHead(targetRes.statusCode, targetRes.headers);\n\n        // 将目标响应的数据流转发到客户端\n        targetRes.pipe(clientRes, {\n            end: true,\n        });\n    });\n\n    req.on(\"error\", (error) => {\n        console.error(\"Error forwarding request:\", error);\n        clientRes.writeHead(500, { \"Content-Type\": \"text/plain\" });\n        clientRes.end(\"Internal Server Error\");\n    });\n\n    // 结束目标请求\n    req.end();\n}\n\n\nfunction safeParse(data) {\n    try {\n        return JSON.parse(data) || {};\n    } catch (e) {\n        return {};\n    }\n}\n\n\nfunction startServer(port) {\n\n    // 创建一个 HTTP 服务器\n    const server = http.createServer((req, res) => {\n        if (req.method !== \"GET\") {\n            res.writeHead(405, { \"Content-Type\": \"text/plain\" });\n            return res.end(\"Only GET requests are allowed\");\n        }\n\n        if (req.url === \"/heartbeat\") {\n            res.writeHead(200, { \"Content-Type\": \"text/plain\" });\n            return res.end(\"OK\");\n        }\n\n        const query = new URLSearchParams(req.url.slice(1));\n\n\n        const url = query.get(\"url\");\n        const method = query.get(\"method\") || \"GET\"; // 默认使用 GET 方法\n        const headers = safeParse(query.get(\"headers\"));\n\n        res.setHeader(\"Access-Control-Allow-Origin\", \"*\"); // 允许所有源\n        res.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, OPTIONS\"); // 允许的方法\n\n        if (!url) {\n            res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n            return res.end(\"Bad Request: Missing URL\");\n        }\n\n        forwardRequest(res, url, method, {\n            ...(req.headers || {}),\n            ...(headers || {})\n        });\n    });\n\n    server.listen(port, () => {\n        process.send?.({\n            type: \"port\",\n            port\n        });\n        console.log(`Proxy server is running on http://localhost:${port}`);\n    });\n\n    server.on(\"error\", (err) => {\n        console.error(\"Server error:\", err);\n        if (retryCount < maxRetries) {\n            retryCount++;\n            const newPort = port + 1; // 尝试下一个端口\n            console.log(`Retrying on port: ${newPort} (attempt ${retryCount})`);\n            startServer(newPort);\n        } else {\n            process.send?.({ type: \"error\", error: \"Max retries reached\" });\n        }\n    })\n}\n\n\nstartServer(defaultPort);\n"
  },
  {
    "path": "res/lang/en-US.json",
    "content": "{\n  \"common\": {\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"download\": \"Download\",\n    \"downloading\": \"Downloading\",\n    \"downloaded\": \"Downloaded\",\n    \"remove\": \"Remove\",\n    \"delete\": \"Delete\",\n    \"default\": \"Default\",\n    \"version_code\": \"Version Code\",\n    \"operation\": \"Operation\",\n    \"update\": \"Update\",\n    \"uninstall\": \"Uninstall\",\n    \"install\": \"Install\",\n    \"about\": \"About\",\n    \"exit\": \"Exit\",\n    \"edit\": \"Edit\",\n    \"undo\": \"Undo\",\n    \"redo\": \"Redo\",\n    \"cut\": \"Cut\",\n    \"copy\": \"Copy\",\n    \"paste\": \"Paste\",\n    \"select_all\": \"Select All\",\n    \"loading\": \"Loading\",\n    \"create\": \"Create\",\n    \"add\": \"Add\",\n    \"save\": \"Save\",\n    \"clear\": \"Clear\",\n    \"open\": \"Open\",\n    \"status\": \"Status\"\n  },\n\n  \"media\": {\n    \"unknown_title\": \"Untitled\",\n    \"unknown_artist\": \"Unknown Artist\",\n    \"unknown_album\": \"Unknown Album\",\n\n    \"default_favorite_sheet_name\": \"Favorites\",\n\n    \"playlist\": \"Playlist\",\n\n    \"media_type_music\": \"Music\",\n    \"media_type_album\": \"Album\",\n    \"media_type_artist\": \"Artist\",\n    \"media_type_sheet\": \"Playlist\",\n    \"media_type_lyric\": \"Lyric\",\n    \"media_type_comment\": \"Comment\",\n\n    \"media_title\": \"Title\",\n    \"media_platform\": \"Source\",\n    \"media_duration\": \"Duration\",\n    \"media_create_at\": \"Created At\",\n    \"media_play_count\": \"Play Count\",\n    \"media_music_count\": \"Song Count\",\n    \"media_description\": \"Description\",\n\n    \"music_state_pause\": \"Pause\",\n    \"music_state_play\": \"Play\",\n    \"music_state_play_or_pause\": \"Play/Pause\",\n    \"music_quality_low\": \"Low Quality\",\n    \"music_quality_standard\": \"Standard Quality\",\n    \"music_quality_high\": \"High Quality\",\n    \"music_quality_super\": \"Super Quality\",\n\n    \"music_repeat_mode\": \"Repeat Mode\",\n    \"music_repeat_mode_loop\": \"Single Loop\",\n    \"music_repeat_mode_queue\": \"Queue Loop\",\n    \"music_repeat_mode_shuffle\": \"Shuffle Play\"\n  },\n\n  \"plugin\": {\n    \"prop_user_variable\": \"User Variable\",\n    \"method_search\": \"Search\",\n    \"method_import_music_item\": \"Import Song\",\n    \"method_import_music_sheet\": \"Import Playlist\",\n    \"method_get_top_lists\": \"Top Charts\",\n\n    \"info_hint_you_have_no_plugin\": \"You have no plugins installed\",\n    \"info_hint_you_have_no_plugin_with_supported_method\": \"You have no plugins installed that support <highlight>{{supportMethod}}</highlight> feature\",\n    \"info_hint_install_plugin_before_use\": \"Go to <a>Plugin Management</a> to install plugins~\"\n  },\n\n  \"download_page\": {\n    \"waiting\": \"Waiting...\",\n    \"failed\": \"Download Failed\"\n  },\n  \"plugin_management_page\": {\n    \"plugin_management\": \"Plugin Management\",\n    \"choose_plugin\": \"Choose Plugin\",\n    \"install\": \"Install\",\n    \"musicfree_plugin\": \"MusicFree Plugin\",\n    \"install_successfully\": \"Plugin Installed Successfully\",\n    \"install_failed\": \"Install Failed\",\n    \"invalid_plugin\": \"Invalid Plugin\",\n    \"install_from_local_file\": \"Install from Local File\",\n    \"install_from_network\": \"Install from Network\",\n    \"install_plugin_from_network\": \"Install Plugin from Network\",\n    \"installing\": \"Installing\",\n    \"info_hint_install_placeholder\": \"Please enter the plugin source URL (link ends with .json or .js)\",\n    \"error_hint_plugin_should_end_with_js_or_json\": \"Plugin URL must end with .json or .js\",\n    \"info_hint_install_plugin\": \"Plugins must comply with the MusicFree plugin protocol. For details, visit the <a>official website</a>\",\n    \"subscription_setting\": \"Subscription Settings\",\n    \"update_subscription\": \"Update Subscription\",\n    \"update_successfully\": \"Update Successful\",\n    \"no_subscription\": \"No Current Subscriptions\",\n\n    \"uninstall\": \"Uninstall\",\n    \"uninstall_plugin\": \"Uninstall Plugin\",\n    \"confirm_text_uninstall_plugin\": \"Confirm to uninstall plugin {{plugin}}?\",\n    \"uninstall_successfully\": \"Uninstalled {{plugin}} successfully\",\n    \"uninstall_failed\": \"Uninstall Failed\",\n\n    \"toast_plugin_is_latest\": \"Plugin {{plugin}} is up to date\",\n    \"update_failed\": \"Update Failed\",\n\n    \"update\": \"Update\",\n\n    \"importing_media\": \"Importing\",\n    \"placeholder_import_music_item\": \"Enter {{plugin}} song link\",\n    \"import_failed\": \"Import Failed\",\n    \"placeholder_import_music_sheet\": \"Enter {{plugin}} playlist link\"\n  },\n  \"local_music_page\": {\n    \"local_music\": \"Local Music\",\n    \"auto_scan\": \"Auto Scan\",\n    \"search_local_music\": \"Search Local Music\",\n    \"list_view\": \"List View\",\n    \"artist_view\": \"Artist View\",\n    \"album_view\": \"Album View\",\n    \"folder_view\": \"Folder View\",\n    \"total_music_num\": \"Total {{number}} songs\"\n  },\n\n  \"music_list_context_menu\": {\n    \"next_play\": \"Play Next\",\n    \"add_to_my_sheets\": \"Add to Playlist\",\n    \"remove_from_sheet\": \"Remove from Playlist\",\n    \"delete_local_download\": \"Delete Local Download\",\n    \"reveal_local_music_in_file_explorer\": \"Show in File Explorer\",\n    \"reveal_local_music_in_file_explorer_fail\": \"Open Failed: \",\n\n    \"delete_local_downloaded_songs_success\": \"Deleted {{musicNums}} local songs\",\n    \"delete_local_downloaded_song_success\": \"Deleted local song [{{songName}}]\"\n  },\n\n  \"search_result_page\": {\n    \"search_result_title\": \"Search Results for\"\n  },\n  \"side_bar\": {\n    \"toplist\": \"Top Charts\",\n    \"recommend_sheets\": \"Popular Playlists\",\n    \"download_management\": \"Download Management\",\n    \"local_music\": \"Local Music\",\n    \"plugin_management\": \"Plugin Management\",\n\n    \"my_sheets\": \"My Playlists\",\n    \"create_local_sheet\": \"Create Playlist\",\n    \"starred_sheets\": \"Favorites\",\n\n    \"delete_sheet\": \"Delete\",\n    \"rename_sheet\": \"Rename\",\n    \"unstar_sheet\": \"Unstar\",\n    \"recently_play\": \"Recently Play\"\n  },\n  \"app_header\": {\n    \"nav_back\": \"Back\",\n    \"nav_forward\": \"Forward\",\n    \"search_placeholder\": \"Enter search content here\",\n    \"search_history\": \"Search History\",\n    \"settings\": \"Settings\",\n    \"minimize\": \"Minimize\",\n    \"minimode\": \"Minimode\",\n    \"exit\": \"Exit\",\n    \"theme\": \"Theme\"\n  },\n  \"music_bar\": {\n    \"open_music_detail_page\": \"Open Song Details\",\n    \"close_music_detail_page\": \"Close Song Details\",\n    \"previous_music\": \"Previous\",\n    \"next_music\": \"Next\",\n    \"mute\": \"Mute\",\n    \"unmute\": \"Unmute\",\n    \"playback_speed\": \"Playback Speed\",\n    \"choose_music_quality\": \"Change Quality\",\n    \"only_set_for_current_music\": \"Only for Current Song\",\n    \"desktop_lyric\": \"Desktop Lyric\"\n  },\n  \"music_detail\": {\n    \"search_lyric\": \"Search Lyric\",\n    \"no_lyric\": \"No Lyrics\",\n\n    \"lyric_ctx_download_lyric\": \"Download Lyric\",\n    \"lyric_ctx_download_lyric_lrc\": \"Download Lyric (.lrc)\",\n    \"lyric_ctx_download_lyric_txt\": \"Download Lyric (.txt)\",\n    \"lyric_ctx_download_success\": \"Download Successful\",\n    \"lyric_ctx_download_fail\": \"Download Failed\",\n    \"lyric_ctx_set_font_size\": \"Set Font Size\",\n\n    \"link_media_lyric\": \"Link Lyric\",\n    \"media_lyric_linked\": \"Lyric Linked: \",\n    \"unlink_media_lyric\": \"Unlink Lyric\",\n    \"toast_media_lyric_unlinked\": \"Lyric Unlinked\",\n    \"translation\": \"Translation\",\n    \"show_translation\": \"Show Translation\",\n    \"hide_translation\": \"Hide Translation\"\n  },\n  \"bottom_loading_state\": {\n    \"reached_end\": \"~~~ End ~~~\",\n    \"loading\": \"Loading...\",\n    \"load_more\": \"Load More\"\n  },\n  \"empty\": {\n    \"hint_empty\": \"Nothing here~~~\"\n  },\n  \"modal\": {\n    \"add_to_my_sheets\": \"Add to Playlist\",\n    \"total_music_num\": \"Total {{number}} songs\",\n    \"create_local_sheet\": \"Create Playlist\",\n    \"create_local_sheet_placeholder\": \"Enter new playlist name\",\n    \"exit_confirm\": \"Confirm Exit?\",\n    \"plugin_subscription\": \"Plugin Subscription\",\n    \"subscription_remarks\": \"Remarks: \",\n    \"subscription_links\": \"Links: \",\n    \"subscription_save_success\": \"Subscription Address Saved\",\n    \"search_lyric\": \"Search Lyric\",\n    \"search_lyric_result_empty\": \"No Search Results\",\n    \"media_lyric_linked\": \"Lyric Linked~\",\n    \"media_lyric_link_failed\": \"Link Lyric Failed:\",\n    \"new_version_found\": \"New Version Found\",\n    \"latest_version\": \"Latest Version: \",\n    \"current_version\": \"Current Version: \",\n    \"skip_this_version\": \"Skip This Version\",\n    \"scan_local_music\": \"Scan Local Music\",\n    \"scan_local_music_hint\": \"Automatically scan selected folders (real-time sync with file changes)\",\n    \"add_folder\": \"Add Folder\"\n  },\n  \"panel\": {\n    \"play_list_song_num\": \"Playlist ({{number}} songs)\",\n    \"user_variable\": \"User Variable\",\n    \"user_variable_setting_success\": \"Setting Successful~\"\n  },\n  \"music_sheet_like_view\": {\n    \"play_all\": \"Play All\",\n    \"add_to_sheet\": \"Add to Playlist\",\n    \"star\": \"Star\"\n  },\n  \"settings\": {\n    \"choose_path\": \"Choose Path\",\n    \"change_path\": \"Change Path\",\n    \"folder_not_exist\": \"Folder Not Exist\",\n    \"open_folder\": \"Open Folder\",\n\n    \"section_name\": {\n      \"normal\": \"General\",\n      \"play_music\": \"Playback\",\n      \"download\": \"Download\",\n      \"lyric\": \"Lyric\",\n      \"plugin\": \"Plugin\",\n      \"theme\": \"Theme\",\n      \"short_cut\": \"Shortcuts\",\n      \"network\": \"Network\",\n      \"backup\": \"Backup & Restore\",\n      \"about\": \"About MusicFree\"\n    },\n    \"normal\": {\n      \"check_update\": \"Check for updates on startup\",\n      \"auto_load_more\": \"(Playlist Page) Automatically load more when scrolling to the bottom\",\n      \"close_behavior\": \"When clicking exit button\",\n      \"exit_app\": \"Exit App\",\n      \"minimize\": \"Minimize to tray\",\n      \"taskbar_thumb\": \"Taskbar thumbnail style (effective after restart)\",\n      \"current_artwork\": \"Current song artwork\",\n      \"main_window\": \"Main window interface\",\n      \"max_history_length\": \"Maximum search history entries\",\n      \"music_list_hide_columns\": \"Hide columns in song list\",\n      \"languages\": \"Languages\",\n      \"toast_switch_language_fail\": \"Switching language failed\"\n    },\n    \"play_music\": {\n      \"case_sensitive_in_search\": \"Case-sensitive search in playlist\",\n      \"default_play_quality\": \"Default playback quality\",\n      \"when_quality_missing\": \"When playback quality is missing\",\n      \"play_lower_quality_version\": \"Play lower quality version\",\n      \"play_higher_quality_version\": \"Play higher quality version\",\n      \"play_skip_quality_version\": \"Do not look for other quality versions\",\n      \"when_play_error\": \"When playback error occurs\",\n      \"pause\": \"Pause\",\n      \"skip_to_next\": \"Automatically play next song\",\n      \"double_click_music_list\": \"When double-clicking the music list\",\n      \"add_music_to_playlist\": \"Add the selected song to the playback queue\",\n      \"replace_playlist_with_musiclist\": \"Replace playback queue with current music list\",\n      \"audio_output_device\": \"Audio output device\",\n      \"when_device_removed\": \"When device is removed\",\n      \"continue_playing\": \"Continue playing\"\n    },\n    \"download\": {\n      \"download_folder\": \"Download Directory\",\n      \"max_concurrency\": \"Maximum concurrent downloads\",\n      \"default_download_quality\": \"Default download quality\",\n      \"when_quality_missing\": \"When download quality is missing\",\n      \"download_lower_quality_version\": \"Download lower quality version\",\n      \"download_higher_quality_version\": \"Download higher quality version\"\n    },\n    \"lyric\": {\n      \"enable_status_bar_lyric\": \"Enable status bar lyric\",\n      \"enable_desktop_lyric\": \"Enable desktop lyric\",\n      \"lock_desktop_lyric\": \"Lock desktop lyric\",\n      \"font\": \"Font\",\n      \"font_size\": \"Font Size\",\n      \"font_color\": \"Font Color\",\n      \"stroke_color\": \"Stroke Color\"\n    },\n    \"plugin\": {\n      \"auto_update_plugin\": \"Automatically update plugins on startup\",\n      \"not_check_plugin_version\": \"Do not check plugin version when installing\"\n    },\n    \"short_cut\": {\n      \"enable_local\": \"Enable in-app shortcuts\",\n      \"enable_global\": \"Enable global shortcuts\",\n      \"ability\": \"Function\",\n      \"local_short_cut\": \"App Shortcuts\",\n      \"global_short_cut\": \"Global Shortcuts\",\n      \"play/pause\": \"Play/Pause\",\n      \"skip-next\": \"Play Next\",\n      \"skip-previous\": \"Play Previous\",\n      \"volume-up\": \"Volume Up\",\n      \"volume-down\": \"Volume Down\",\n      \"toggle-desktop-lyric\": \"Toggle Desktop Lyric\",\n      \"like/dislike\": \"Like/Dislike Current Song\",\n      \"no_short_cut\": \"None\",\n      \"toggle-main-window-visible\": \"Show/Hide Main Window\"\n    },\n    \"network\": {\n      \"host\": \"Host\",\n      \"port\": \"Port\",\n      \"username\": \"Username\",\n      \"password\": \"Password\",\n      \"local_cache\": \"Local Cache: {{cacheSize}}\",\n      \"clear_cache\": \"Clear Cache\",\n      \"enable_network_proxy\": \"Enable Network Proxy\"\n    },\n    \"backup\": {\n      \"resume_mode_append\": \"Append to existing playlist\",\n      \"resume_mode_overwrite\": \"Overwrite existing playlist\",\n      \"backup_by_file\": \"File Backup\",\n      \"musicfree_backup_file\": \"MusicFree Backup File\",\n      \"backup_to\": \"Backup to...\",\n      \"backup_by_webdav\": \"WebDAV Backup\",\n      \"backup_success\": \"Backup Successful~\",\n      \"backup_fail\": \"Backup Failed: {{reason}}\",\n      \"resume_success\": \"Restore Successful~\",\n      \"resume_fail\": \"Restore Failed: {{reason}}\",\n      \"backup_music_sheet\": \"Backup Playlist\",\n      \"resume_music_sheet\": \"Restore Playlist\",\n      \"webdav_server_url\": \"URL\",\n      \"username\": \"Username\",\n      \"password\": \"Password\",\n      \"webdav_data_not_complete\": \"URL, username, and password cannot be empty\",\n      \"webdav_backup_file_not_exist\": \"Backup file does not exist\"\n    },\n    \"about\": {\n      \"current_version\": \"Current Version: {{version}}\",\n      \"already_latest\": \"Already up to date!\",\n      \"check_update\": \"Check for Update\",\n      \"software_author\": \"Software Author: \",\n      \"open_source_declaration\": \"Source Code: Software is open-source under AGPL3.0 license. <Github>Github Link</Github> <Gitee>Gitee Link</Gitee>\",\n      \"official_site\": \"Official Website\",\n      \"mobile_version\": \"Mobile Version\"\n    }\n  },\n  \"main\": {\n    \"previous_music\": \"Previous Song\",\n    \"next_music\": \"Next Song\",\n    \"close_desktop_lyric\": \"Close Desktop Lyrics\",\n    \"open_desktop_lyric\": \"Open Desktop Lyrics\",\n    \"unlock_desktop_lyric\": \"Unlock Desktop Lyrics\",\n    \"lock_desktop_lyric\": \"Lock Desktop Lyrics\",\n    \"no_playing_music\": \"No Music Playing\"\n  },\n  \"theme\": {\n    \"tab_local\": \"Local Theme\",\n    \"tab_remote\": \"Theme Marketplace\",\n    \"download_and_use\": \"Download and Use\",\n    \"use_theme\": \"Use Theme\",\n    \"install_theme\": \"Install Theme\",\n    \"update_theme\": \"Update Theme\",\n    \"uninstall_theme\": \"Uninstall Theme\",\n    \"musicfree_theme\": \"MusicFree Theme\",\n    \"all_files\": \"All Files\",\n    \"install_theme_success\": \"Successfully installed theme {{name}}~\",\n    \"install_theme_fail\": \"Failed to install theme: {{reason}}\",\n    \"uninstall_theme_success\": \"Successfully uninstalled theme {{name}}~\",\n    \"uninstall_theme_fail\": \"Failed to uninstall theme: {{reason}}\",\n    \"how_to_submit_new_theme\": \"💡How to submit a new theme: The themes in the theme marketplace are synchronized with the <Github>MusicFreeThemePacks</Github> repository. If you need to submit a new theme, please make a pull request directly.\",\n    \"load_remote_theme_error\": \"An error occurred...\",\n    \"invalid_theme\": \"Invalid theme: {{reason}}\"\n  }\n}\n"
  },
  {
    "path": "res/lang/zh-CN.json",
    "content": "{\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"confirm\": \"确认\",\n    \"download\": \"下载\",\n    \"downloading\": \"下载中\",\n    \"downloaded\": \"已下载\",\n    \"remove\": \"删除\",\n    \"delete\": \"删除\",\n    \"default\": \"默认\",\n    \"version_code\": \"版本号\",\n    \"operation\": \"操作\",\n    \"update\": \"更新\",\n    \"uninstall\": \"卸载\",\n    \"install\": \"安装\",\n    \"about\": \"关于\",\n    \"exit\": \"退出\",\n    \"edit\": \"编辑\",\n    \"undo\": \"撤销\",\n    \"redo\": \"恢复\",\n    \"cut\": \"剪切\",\n    \"copy\": \"复制\",\n    \"paste\": \"粘贴\",\n    \"select_all\": \"全选\",\n    \"loading\": \"加载中\",\n    \"create\": \"创建\",\n    \"add\": \"添加\",\n    \"save\": \"保存\",\n    \"clear\": \"清空\",\n    \"open\": \"打开\",\n    \"status\": \"状态\"\n  },\n  \"media\": {\n    \"unknown_title\": \"未命名\",\n    \"unknown_artist\": \"未知作者\",\n    \"unknown_album\": \"未知专辑\",\n    \"default_favorite_sheet_name\": \"我喜欢\",\n    \"playlist\": \"播放列表\",\n    \"media_type_music\": \"音乐\",\n    \"media_type_album\": \"专辑\",\n    \"media_type_artist\": \"作者\",\n    \"media_type_sheet\": \"歌单\",\n    \"media_type_lyric\": \"歌词\",\n    \"media_type_comment\": \"评论\",\n    \"media_title\": \"标题\",\n    \"media_platform\": \"来源\",\n    \"media_duration\": \"时长\",\n    \"media_create_at\": \"创建时间\",\n    \"media_play_count\": \"播放数\",\n    \"media_music_count\": \"歌曲数\",\n    \"media_description\": \"简介\",\n    \"music_state_pause\": \"暂停\",\n    \"music_state_play\": \"播放\",\n    \"music_state_play_or_pause\": \"播放/暂停\",\n    \"music_quality_low\": \"低音质\",\n    \"music_quality_standard\": \"标准音质\",\n    \"music_quality_high\": \"高音质\",\n    \"music_quality_super\": \"超高音质\",\n    \"music_repeat_mode\": \"播放模式\",\n    \"music_repeat_mode_loop\": \"单曲循环\",\n    \"music_repeat_mode_queue\": \"列表循环\",\n    \"music_repeat_mode_shuffle\": \"随机播放\"\n  },\n  \"plugin\": {\n    \"prop_user_variable\": \"用户变量\",\n    \"method_search\": \"搜索\",\n    \"method_import_music_item\": \"导入单曲\",\n    \"method_import_music_sheet\": \"导入歌单\",\n    \"method_get_top_lists\": \"排行榜\",\n    \"info_hint_you_have_no_plugin\": \"你还没有安装插件\",\n    \"info_hint_you_have_no_plugin_with_supported_method\": \"你还没有安装支持<highlight> {{supportMethod}} </highlight>功能的插件\",\n    \"info_hint_install_plugin_before_use\": \"先去<a>插件管理</a> 安装插件吧~\"\n  },\n  \"download_page\": {\n    \"waiting\": \"等待中...\",\n    \"failed\": \"下载失败\"\n  },\n  \"plugin_management_page\": {\n    \"plugin_management\": \"插件管理\",\n    \"choose_plugin\": \"选择插件\",\n    \"install\": \"安装\",\n    \"musicfree_plugin\": \"MusicFree插件\",\n    \"install_successfully\": \"插件安装成功\",\n    \"install_failed\": \"安装失败\",\n    \"invalid_plugin\": \"无效插件\",\n    \"install_from_local_file\": \"从本地文件安装\",\n    \"install_from_network\": \"从网络安装\",\n    \"install_plugin_from_network\": \"从网络安装插件\",\n    \"installing\": \"正在安装\",\n    \"info_hint_install_placeholder\": \"请输入插件源地址(链接以json或js结尾)\",\n    \"error_hint_plugin_should_end_with_js_or_json\": \"插件链接需要以json或者js结尾\",\n    \"info_hint_install_plugin\": \"插件需要满足 MusicFree 特定的插件协议，具体可在<a>官方网站</a>中查看\",\n    \"subscription_setting\": \"订阅设置\",\n    \"update_subscription\": \"更新订阅\",\n    \"update_successfully\": \"更新成功\",\n    \"no_subscription\": \"当前无订阅\",\n    \"uninstall\": \"卸载\",\n    \"uninstall_plugin\": \"卸载插件\",\n    \"confirm_text_uninstall_plugin\": \"确认卸载插件 {{plugin}} 吗?\",\n    \"uninstall_successfully\": \"已卸载 {{plugin}}\",\n    \"uninstall_failed\": \"卸载失败\",\n    \"toast_plugin_is_latest\": \"插件 {{plugin}} 已更新到最新版本\",\n    \"update_failed\": \"更新失败\",\n    \"update\": \"更新\",\n    \"importing_media\": \"正在导入中\",\n    \"placeholder_import_music_item\": \"输入 {{plugin}} 单曲链接\",\n    \"import_failed\": \"导入失败\",\n    \"placeholder_import_music_sheet\": \"输入 {{plugin}} 歌单链接\"\n  },\n  \"local_music_page\": {\n    \"local_music\": \"本地音乐\",\n    \"auto_scan\": \"自动扫描\",\n    \"search_local_music\": \"搜索本地音乐\",\n    \"list_view\": \"列表视图\",\n    \"artist_view\": \"作者视图\",\n    \"album_view\": \"专辑视图\",\n    \"folder_view\": \"文件夹视图\",\n    \"total_music_num\": \"共 {{number}} 首\"\n  },\n  \"music_list_context_menu\": {\n    \"next_play\": \"下一首播放\",\n    \"add_to_my_sheets\": \"添加到歌单\",\n    \"remove_from_sheet\": \"从歌单内删除\",\n    \"delete_local_download\": \"删除本地下载\",\n    \"reveal_local_music_in_file_explorer\": \"打开歌曲所在文件夹\",\n    \"reveal_local_music_in_file_explorer_fail\": \"打开失败: \",\n    \"delete_local_downloaded_songs_success\": \"已删除 {{musicNums}} 首本地歌曲\",\n    \"delete_local_downloaded_song_success\": \"已删除本地歌曲 [{{songName}}]\"\n  },\n  \"search_result_page\": {\n    \"search_result_title\": \"的搜索结果\"\n  },\n  \"side_bar\": {\n    \"toplist\": \"排行榜\",\n    \"recommend_sheets\": \"热门歌单\",\n    \"download_management\": \"下载管理\",\n    \"local_music\": \"本地音乐\",\n    \"plugin_management\": \"插件管理\",\n    \"my_sheets\": \"我的歌单\",\n    \"create_local_sheet\": \"新建歌单\",\n    \"starred_sheets\": \"我的收藏\",\n    \"delete_sheet\": \"删除歌单\",\n    \"rename_sheet\": \"重命名歌单\",\n    \"unstar_sheet\": \"取消收藏\",\n    \"recently_play\": \"最近播放\"\n  },\n  \"app_header\": {\n    \"nav_back\": \"后退\",\n    \"nav_forward\": \"前进\",\n    \"search_placeholder\": \"在这里输入搜索内容\",\n    \"search_history\": \"搜索历史\",\n    \"settings\": \"设置\",\n    \"minimize\": \"最小化\",\n    \"minimode\": \"迷你模式\",\n    \"exit\": \"退出\",\n    \"theme\": \"主题\"\n  },\n  \"music_bar\": {\n    \"open_music_detail_page\": \"打开歌曲详情页\",\n    \"close_music_detail_page\": \"关闭歌曲详情页\",\n    \"previous_music\": \"上一首\",\n    \"next_music\": \"下一首\",\n    \"mute\": \"静音\",\n    \"unmute\": \"恢复音量\",\n    \"playback_speed\": \"倍速播放\",\n    \"choose_music_quality\": \"切换音质\",\n    \"only_set_for_current_music\": \"仅设置当前歌曲\",\n    \"desktop_lyric\": \"桌面歌词\"\n  },\n  \"music_detail\": {\n    \"search_lyric\": \"搜索歌词\",\n    \"no_lyric\": \"暂无歌词\",\n    \"lyric_ctx_download_lyric\": \"下载歌词\",\n    \"lyric_ctx_download_lyric_lrc\": \"下载歌词 (.lrc)\",\n    \"lyric_ctx_download_lyric_txt\": \"下载歌词 (.txt)\",\n    \"lyric_ctx_download_success\": \"下载成功\",\n    \"lyric_ctx_download_fail\": \"下载失败\",\n    \"lyric_ctx_set_font_size\": \"设置字号\",\n    \"link_media_lyric\": \"关联歌词\",\n    \"media_lyric_linked\": \"已关联歌词: \",\n    \"unlink_media_lyric\": \"取消关联歌词\",\n    \"toast_media_lyric_unlinked\": \"已取消关联歌词\",\n    \"translation\": \"翻译\",\n    \"show_translation\": \"显示翻译\",\n    \"hide_translation\": \"隐藏翻译\"\n  },\n  \"bottom_loading_state\": {\n    \"reached_end\": \"~~~ 到底啦 ~~~\",\n    \"loading\": \"加载中...\",\n    \"load_more\": \"加载更多\"\n  },\n  \"empty\": {\n    \"hint_empty\": \"什么都没有呀~~~\"\n  },\n  \"modal\": {\n    \"add_to_my_sheets\": \"添加到歌单\",\n    \"total_music_num\": \"共 {{number}} 首\",\n    \"create_local_sheet\": \"新建歌单\",\n    \"create_local_sheet_placeholder\": \"请输入新建歌单名称\",\n    \"exit_confirm\": \"确认退出?\",\n    \"plugin_subscription\": \"插件订阅\",\n    \"subscription_remarks\": \"备注: \",\n    \"subscription_links\": \"链接: \",\n    \"subscription_save_success\": \"已保存订阅地址\",\n    \"search_lyric\": \"搜索歌词\",\n    \"search_lyric_result_empty\": \"搜索结果为空\",\n    \"media_lyric_linked\": \"已关联歌词~\",\n    \"media_lyric_link_failed\": \"关联歌词失败:\",\n    \"new_version_found\": \"发现新版本\",\n    \"latest_version\": \"最新版本: \",\n    \"current_version\": \"当前版本: \",\n    \"skip_this_version\": \"跳过此版本\",\n    \"scan_local_music\": \"扫描本地音乐\",\n    \"scan_local_music_hint\": \"将自动扫描勾选的文件夹 (文件增删实时同步)\",\n    \"add_folder\": \"添加文件夹\"\n  },\n  \"panel\": {\n    \"play_list_song_num\": \"播放列表 ({{number}}首)\",\n    \"user_variable\": \"用户变量\",\n    \"user_variable_setting_success\": \"设置成功~\"\n  },\n  \"music_sheet_like_view\": {\n    \"play_all\": \"播放\",\n    \"add_to_sheet\": \"添加\",\n    \"star\": \"收藏\"\n  },\n  \"settings\": {\n    \"choose_path\": \"选择路径\",\n    \"change_path\": \"更改路径\",\n    \"folder_not_exist\": \"文件夹不存在\",\n    \"open_folder\": \"打开文件夹\",\n    \"section_name\": {\n      \"normal\": \"常规\",\n      \"play_music\": \"播放\",\n      \"download\": \"下载\",\n      \"lyric\": \"歌词\",\n      \"plugin\": \"插件\",\n      \"theme\": \"主题\",\n      \"short_cut\": \"快捷键\",\n      \"network\": \"网络\",\n      \"backup\": \"备份与恢复\",\n      \"about\": \"关于 MusicFree\"\n    },\n    \"normal\": {\n      \"check_update\": \"应用启动时检测软件版本更新\",\n      \"auto_load_more\": \"(歌单页) 滑动到页面底部时自动加载更多\",\n      \"close_behavior\": \"单击退出按钮时\",\n      \"exit_app\": \"退出应用\",\n      \"minimize\": \"最小化到托盘\",\n      \"taskbar_thumb\": \"任务栏缩略图样式（重启应用后生效）\",\n      \"current_artwork\": \"当前播放歌曲的封面\",\n      \"main_window\": \"主窗口界面\",\n      \"max_history_length\": \"搜索历史记录最多保存条数\",\n      \"music_list_hide_columns\": \"歌曲列表隐藏列\",\n      \"languages\": \"语言\",\n      \"toast_switch_language_fail\": \"切换语言失败\"\n    },\n    \"play_music\": {\n      \"case_sensitive_in_search\": \"歌单内搜索时区分大小写\",\n      \"default_play_quality\": \"默认播放音质\",\n      \"when_quality_missing\": \"播放音质缺失时\",\n      \"play_lower_quality_version\": \"播放更低音质\",\n      \"play_higher_quality_version\": \"播放更高音质\",\n      \"play_skip_quality_version\": \"不寻找其他音质版本\",\n      \"when_play_error\": \"播放失败时\",\n      \"pause\": \"暂停播放\",\n      \"skip_to_next\": \"自动播放下一首\",\n      \"double_click_music_list\": \"双击音乐列表时\",\n      \"add_music_to_playlist\": \"将目标单曲添加到播放队列\",\n      \"replace_playlist_with_musiclist\": \"使用当前音乐列表替换播放队列\",\n      \"audio_output_device\": \"音频输出设备\",\n      \"when_device_removed\": \"音频设备移除时\",\n      \"continue_playing\": \"继续播放\"\n    },\n    \"download\": {\n      \"download_folder\": \"下载目录\",\n      \"max_concurrency\": \"最多同时下载歌曲数\",\n      \"default_download_quality\": \"默认下载音质\",\n      \"when_quality_missing\": \"下载音质缺失时\",\n      \"download_lower_quality_version\": \"下载更低音质\",\n      \"download_higher_quality_version\": \"下载更高音质\"\n    },\n    \"lyric\": {\n      \"enable_status_bar_lyric\": \"启用状态栏歌词\",\n      \"enable_desktop_lyric\": \"启用桌面歌词\",\n      \"lock_desktop_lyric\": \"锁定桌面歌词\",\n      \"font\": \"字体\",\n      \"font_size\": \"字体大小\",\n      \"font_color\": \"字体颜色\",\n      \"stroke_color\": \"描边颜色\"\n    },\n    \"plugin\": {\n      \"auto_update_plugin\": \"打开软件时自动更新插件\",\n      \"not_check_plugin_version\": \"安装插件时不校验版本\"\n    },\n    \"short_cut\": {\n      \"enable_local\": \"启用软件内快捷键\",\n      \"enable_global\": \"启用全局快捷键\",\n      \"ability\": \"功能\",\n      \"local_short_cut\": \"软件快捷键\",\n      \"global_short_cut\": \"全局快捷键\",\n      \"play/pause\": \"播放/暂停\",\n      \"skip-next\": \"播放下一首\",\n      \"skip-previous\": \"播放上一首\",\n      \"volume-up\": \"增加音量\",\n      \"volume-down\": \"降低音量\",\n      \"toggle-desktop-lyric\": \"打开/关闭桌面歌词\",\n      \"like/dislike\": \"喜欢/不喜欢当前歌曲\",\n      \"no_short_cut\": \"空\",\n      \"toggle-main-window-visible\": \"显示/隐藏主窗口\"\n    },\n    \"network\": {\n      \"host\": \"主机\",\n      \"port\": \"端口\",\n      \"username\": \"账号\",\n      \"password\": \"密码\",\n      \"local_cache\": \"本地缓存：{{cacheSize}}\",\n      \"clear_cache\": \"清空缓存\",\n      \"enable_network_proxy\": \"启用网络代理\"\n    },\n    \"backup\": {\n      \"resume_mode_append\": \"追加到已有歌单末尾\",\n      \"resume_mode_overwrite\": \"覆盖已有歌单\",\n      \"backup_by_file\": \"文件备份\",\n      \"musicfree_backup_file\": \"MusicFree 备份文件\",\n      \"backup_to\": \"备份到...\",\n      \"backup_by_webdav\": \"WebDAV 备份\",\n      \"backup_success\": \"备份成功~\",\n      \"backup_fail\": \"备份失败：{{reason}}\",\n      \"resume_success\": \"恢复成功~\",\n      \"resume_fail\": \"恢复失败：{{reason}}\",\n      \"backup_music_sheet\": \"备份歌单\",\n      \"resume_music_sheet\": \"恢复歌单\",\n      \"webdav_server_url\": \"URL\",\n      \"username\": \"账号\",\n      \"password\": \"密码\",\n      \"webdav_data_not_complete\": \"URL、账号、密码不可为空\",\n      \"webdav_backup_file_not_exist\": \"备份文件不存在\"\n    },\n    \"about\": {\n      \"current_version\": \"当前版本: {{version}}\",\n      \"already_latest\": \"当前已是最新版本!\",\n      \"check_update\": \"检查更新\",\n      \"software_author\": \"软件作者: \",\n      \"open_source_declaration\": \"源代码: 软件基于 AGPL3.0 协议开源.  <Github>Github地址</Github>  <Gitee>Gitee地址</Gitee>\",\n      \"official_site\": \"软件官网\",\n      \"mobile_version\": \"移动版\"\n    }\n  },\n  \"main\": {\n    \"previous_music\": \"上一首\",\n    \"next_music\": \"下一首\",\n    \"close_desktop_lyric\": \"关闭桌面歌词\",\n    \"open_desktop_lyric\": \"开启桌面歌词\",\n    \"unlock_desktop_lyric\": \"解锁桌面歌词\",\n    \"lock_desktop_lyric\": \"锁定桌面歌词\",\n    \"no_playing_music\": \"当前无正在播放的音乐\"\n  },\n  \"theme\": {\n    \"tab_local\": \"本地主题\",\n    \"tab_remote\": \"主题市场\",\n    \"download_and_use\": \"下载并使用\",\n    \"use_theme\": \"使用主题\",\n    \"install_theme\": \"安装主题\",\n    \"update_theme\": \"更新主题\",\n    \"uninstall_theme\": \"卸载主题\",\n    \"musicfree_theme\": \"MusicFree 主题\",\n    \"all_files\": \"全部文件\",\n    \"install_theme_success\": \"安装主题{{name}}成功~\",\n    \"install_theme_fail\": \"安装主题失败: {{reason}}\",\n    \"uninstall_theme_success\": \"卸载主题{{name}}成功~\",\n    \"uninstall_theme_fail\": \"卸载主题失败: {{reason}}\",\n    \"how_to_submit_new_theme\": \"💡如何提交新主题: 主题市场中的主题与 <Github>MusicFreeThemePacks</Github> 仓库同步，如果需要提交新主题请直接提PR。\",\n    \"load_remote_theme_error\": \"出错啦...\",\n    \"invalid_theme\": \"主题无效: {{reason}}\"\n  }\n}"
  },
  {
    "path": "res/lang/zh-TW.json",
    "content": "{\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"confirm\": \"確認\",\n    \"download\": \"下載\",\n    \"downloading\": \"下載中\",\n    \"downloaded\": \"已下載\",\n    \"remove\": \"移除\",\n    \"delete\": \"刪除\",\n    \"default\": \"預設\",\n    \"version_code\": \"版本號\",\n    \"operation\": \"操作\",\n    \"update\": \"更新\",\n    \"uninstall\": \"卸載\",\n    \"install\": \"安裝\",\n    \"about\": \"關於\",\n    \"exit\": \"退出\",\n    \"edit\": \"編輯\",\n    \"undo\": \"撤銷\",\n    \"redo\": \"重做\",\n    \"cut\": \"剪下\",\n    \"copy\": \"複製\",\n    \"paste\": \"貼上\",\n    \"select_all\": \"全選\",\n    \"loading\": \"加載中\",\n    \"create\": \"創建\",\n    \"add\": \"新增\",\n    \"save\": \"保存\",\n    \"clear\": \"清除\",\n    \"open\": \"打開\",\n    \"status\": \"狀態\"\n  },\n\n  \"media\": {\n    \"unknown_title\": \"無標題\",\n    \"unknown_artist\": \"未知藝術家\",\n    \"unknown_album\": \"未知專輯\",\n\n    \"default_favorite_sheet_name\": \"喜愛\",\n\n    \"playlist\": \"播放清單\",\n\n    \"media_type_music\": \"音樂\",\n    \"media_type_album\": \"專輯\",\n    \"media_type_artist\": \"藝術家\",\n    \"media_type_sheet\": \"歌單\",\n    \"media_type_lyric\": \"歌詞\",\n    \"media_type_comment\": \"評論\",\n\n    \"media_title\": \"標題\",\n    \"media_platform\": \"來源\",\n    \"media_duration\": \"時長\",\n    \"media_create_at\": \"創建時間\",\n    \"media_play_count\": \"播放次數\",\n    \"media_music_count\": \"歌曲數量\",\n    \"media_description\": \"描述\",\n\n    \"music_state_pause\": \"暫停\",\n    \"music_state_play\": \"播放\",\n    \"music_state_play_or_pause\": \"播放/暫停\",\n    \"music_quality_low\": \"低音質\",\n    \"music_quality_standard\": \"標準音質\",\n    \"music_quality_high\": \"高音質\",\n    \"music_quality_super\": \"超高音質\",\n\n    \"music_repeat_mode\": \"重複模式\",\n    \"music_repeat_mode_loop\": \"單曲重複\",\n    \"music_repeat_mode_queue\": \"列表重複\",\n    \"music_repeat_mode_shuffle\": \"隨機播放\"\n  },\n\n  \"plugin\": {\n    \"prop_user_variable\": \"用戶變量\",\n    \"method_search\": \"搜尋\",\n    \"method_import_music_item\": \"導入歌曲\",\n    \"method_import_music_sheet\": \"導入歌單\",\n    \"method_get_top_lists\": \"排行榜\",\n\n    \"info_hint_you_have_no_plugin\": \"你尚未安裝任何插件\",\n    \"info_hint_you_have_no_plugin_with_supported_method\": \"你尚未安裝支持 <highlight>{{supportMethod}}</highlight> 的插件\",\n    \"info_hint_install_plugin_before_use\": \"請先前往 <a>插件管理</a> 安裝插件~\"\n  },\n\n  \"download_page\": {\n    \"waiting\": \"等待中...\",\n    \"failed\": \"下載失敗\"\n  },\n  \"plugin_management_page\": {\n    \"plugin_management\": \"插件管理\",\n    \"choose_plugin\": \"選擇插件\",\n    \"install\": \"安裝\",\n    \"musicfree_plugin\": \"MusicFree 插件\",\n    \"install_successfully\": \"插件安裝成功\",\n    \"install_failed\": \"安裝失敗\",\n    \"invalid_plugin\": \"無效的插件\",\n    \"install_from_local_file\": \"從本地文件安裝\",\n    \"install_from_network\": \"從網絡安裝\",\n    \"install_plugin_from_network\": \"從網絡安裝插件\",\n    \"installing\": \"安裝中\",\n    \"info_hint_install_placeholder\": \"輸入插件來源 URL（以 json 或 js 結尾）\",\n    \"error_hint_plugin_should_end_with_js_or_json\": \"插件 URL 應以 json 或 js 結尾\",\n    \"info_hint_install_plugin\": \"插件需符合 MusicFree 特定協議，詳情請參考 <a>官方頁面</a>\",\n    \"subscription_setting\": \"訂閱設定\",\n    \"update_subscription\": \"更新訂閱\",\n    \"update_successfully\": \"更新成功\",\n    \"no_subscription\": \"當前沒有訂閱\",\n\n    \"uninstall\": \"卸載\",\n    \"uninstall_plugin\": \"卸載插件\",\n    \"confirm_text_uninstall_plugin\": \"確認卸載插件 {{plugin}}？\",\n    \"uninstall_successfully\": \"插件 {{plugin}} 已卸載\",\n    \"uninstall_failed\": \"卸載失敗\",\n\n    \"toast_plugin_is_latest\": \"插件 {{plugin}} 已是最新版本\",\n    \"update_failed\": \"更新失敗\",\n\n    \"update\": \"更新\",\n\n    \"importing_media\": \"導入中\",\n    \"placeholder_import_music_item\": \"輸入 {{plugin}} 歌曲 URL\",\n    \"import_failed\": \"導入失敗\",\n    \"placeholder_import_music_sheet\": \"輸入 {{plugin}} 歌單 URL\"\n  },\n  \"local_music_page\": {\n    \"local_music\": \"本地音樂\",\n    \"auto_scan\": \"自動掃描\",\n    \"search_local_music\": \"搜尋本地音樂\",\n    \"list_view\": \"列表視圖\",\n    \"artist_view\": \"藝術家視圖\",\n    \"album_view\": \"專輯視圖\",\n    \"folder_view\": \"文件夾視圖\",\n    \"total_music_num\": \"共 {{number}} 首歌曲\"\n  },\n\n  \"music_list_context_menu\": {\n    \"next_play\": \"下一首播放\",\n    \"add_to_my_sheets\": \"添加到我的歌單\",\n    \"remove_from_sheet\": \"從歌單移除\",\n    \"delete_local_download\": \"刪除本地下載\",\n    \"reveal_local_music_in_file_explorer\": \"打開歌曲文件夾\",\n    \"reveal_local_music_in_file_explorer_fail\": \"打開失敗：\",\n\n    \"delete_local_downloaded_songs_success\": \"已刪除 {{musicNums}} 首本地歌曲\",\n    \"delete_local_downloaded_song_success\": \"已刪除本地歌曲 [{{songName}}]\"\n  },\n\n  \"search_result_page\": {\n    \"search_result_title\": \"搜尋結果\"\n  },\n  \"side_bar\": {\n    \"toplist\": \"排行榜\",\n    \"recommend_sheets\": \"推薦歌單\",\n    \"download_management\": \"下載管理\",\n    \"local_music\": \"本地音樂\",\n    \"plugin_management\": \"插件管理\",\n\n    \"my_sheets\": \"我的歌單\",\n    \"create_local_sheet\": \"創建歌單\",\n    \"starred_sheets\": \"我的收藏\",\n\n    \"delete_sheet\": \"刪除歌單\",\n    \"rename_sheet\": \"重歌單\",\n    \"unstar_sheet\": \"取消收藏\",\n    \"recently_play\": \"最近播放\"\n  },\n  \"app_header\": {\n    \"nav_back\": \"返回\",\n    \"nav_forward\": \"前進\",\n    \"search_placeholder\": \"在這裡輸入搜尋\",\n    \"search_history\": \"搜尋歷史\",\n    \"settings\": \"設置\",\n    \"minimize\": \"最小化\",\n    \"minimode\": \"迷你模式\",\n    \"exit\": \"退出\",\n    \"theme\": \"主題\"\n  },\n  \"music_bar\": {\n    \"open_music_detail_page\": \"打開歌曲詳情\",\n    \"close_music_detail_page\": \"關閉歌曲詳情\",\n    \"previous_music\": \"上一首\",\n    \"next_music\": \"下一首\",\n    \"mute\": \"靜音\",\n    \"unmute\": \"取消靜音\",\n    \"playback_speed\": \"播放速度\",\n    \"choose_music_quality\": \"選擇音樂音質\",\n    \"only_set_for_current_music\": \"僅針對當前歌曲\",\n    \"desktop_lyric\": \"桌面歌詞\"\n  },\n  \"music_detail\": {\n    \"search_lyric\": \"搜尋歌詞\",\n    \"no_lyric\": \"暫無歌詞\",\n\n    \"lyric_ctx_download_lyric\": \"下載歌詞\",\n    \"lyric_ctx_download_lyric_lrc\": \"下載歌詞 (.lrc)\",\n    \"lyric_ctx_download_lyric_txt\": \"下載歌詞 (.txt)\",\n    \"lyric_ctx_download_success\": \"下載成功\",\n    \"lyric_ctx_download_fail\": \"下載失敗\",\n    \"lyric_ctx_set_font_size\": \"設置字體大小\",\n\n    \"link_media_lyric\": \"鏈接歌詞\",\n    \"media_lyric_linked\": \"已鏈接歌詞：\",\n    \"unlink_media_lyric\": \"取消鏈接歌詞\",\n    \"toast_media_lyric_unlinked\": \"已取消鏈接歌詞\",\n    \"translation\": \"翻譯\",\n    \"show_translation\": \"顯示翻譯\",\n    \"hide_translation\": \"隱藏翻譯\"\n  },\n  \"bottom_loading_state\": {\n    \"reached_end\": \"~~~ 沒有更多了 ~~~\",\n    \"loading\": \"加載中...\",\n    \"load_more\": \"加載更多\"\n  },\n  \"empty\": {\n    \"hint_empty\": \"這裡什麼都沒有~~~\"\n  },\n  \"modal\": {\n    \"add_to_my_sheets\": \"添加到我的歌單\",\n    \"total_music_num\": \"共 {{number}} 首歌曲\",\n    \"create_local_sheet\": \"創建歌單\",\n    \"create_local_sheet_placeholder\": \"請輸入新的歌單名稱\",\n    \"exit_confirm\": \"確認退出？\",\n    \"plugin_subscription\": \"插件訂閱\",\n    \"subscription_remarks\": \"備註：\",\n    \"subscription_links\": \"鏈接：\",\n    \"subscription_save_success\": \"訂閱地址已保存\",\n    \"search_lyric\": \"搜尋歌詞\",\n    \"search_lyric_result_empty\": \"未找到相關結果\",\n    \"media_lyric_linked\": \"歌詞已鏈接~\",\n    \"media_lyric_link_failed\": \"鏈接歌詞失敗：\",\n    \"new_version_found\": \"發現新版本\",\n    \"latest_version\": \"最新版本：\",\n    \"current_version\": \"當前版本：\",\n    \"skip_this_version\": \"跳過此版本\",\n    \"scan_local_music\": \"掃描本地音樂\",\n    \"scan_local_music_hint\": \"將自動掃描選擇的文件夾（實時同步）\",\n    \"add_folder\": \"添加文件夾\"\n  },\n  \"panel\": {\n    \"play_list_song_num\": \"播放清單（{{number}} 首歌曲）\",\n    \"user_variable\": \"用戶變量\",\n    \"user_variable_setting_success\": \"設置成功~\"\n  },\n  \"music_sheet_like_view\": {\n    \"play_all\": \"播放全部\",\n    \"add_to_sheet\": \"添加到歌單\",\n    \"star\": \"收藏\"\n  },\n  \"settings\": {\n    \"choose_path\": \"選擇路徑\",\n    \"change_path\": \"更改路徑\",\n    \"folder_not_exist\": \"文件夾不存在\",\n    \"open_folder\": \"打開文件夾\",\n\n    \"section_name\": {\n      \"normal\": \"一般\",\n      \"play_music\": \"播放\",\n      \"download\": \"下載\",\n      \"lyric\": \"歌詞\",\n      \"plugin\": \"插件\",\n      \"theme\": \"主題\",\n      \"short_cut\": \"快捷鍵\",\n      \"network\": \"網絡\",\n      \"backup\": \"備份與恢復\",\n      \"about\": \"關於 MusicFree\"\n    },\n    \"normal\": {\n      \"check_update\": \"啟動應用時檢查更新\",\n      \"auto_load_more\": \"(歌單頁) 滑動到頁面底部時自動加載更多\",\n      \"close_behavior\": \"點擊退出按鈕時\",\n      \"exit_app\": \"退出應用\",\n      \"minimize\": \"最小化到系統托盤\",\n      \"taskbar_thumb\": \"任務欄縮略圖樣式（重啟應用後生效）\",\n      \"current_artwork\": \"當前歌曲封面\",\n      \"main_window\": \"主窗口界面\",\n      \"max_history_length\": \"搜尋歷史記錄最大數量\",\n      \"music_list_hide_columns\": \"歌曲列表隱藏列\",\n      \"languages\": \"語言\",\n      \"toast_switch_language_fail\": \"切換語言失敗\"\n    },\n    \"play_music\": {\n      \"case_sensitive_in_search\": \"搜尋歌單時區分大小寫\",\n      \"default_play_quality\": \"預設播放音質\",\n      \"when_quality_missing\": \"當播放音質缺失時\",\n      \"play_lower_quality_version\": \"播放低音質版本\",\n      \"play_higher_quality_version\": \"播放高音質版本\",\n      \"play_skip_quality_version\": \"不尋找其他音質版本\",\n      \"when_play_error\": \"播放錯誤時\",\n      \"pause\": \"暫停\",\n      \"skip_to_next\": \"跳至下一首\",\n      \"double_click_music_list\": \"雙擊歌曲列表時\",\n      \"add_music_to_playlist\": \"添加歌曲到播放清單\",\n      \"replace_playlist_with_musiclist\": \"用當前歌單替換播放清單\",\n      \"audio_output_device\": \"音頻輸出設備\",\n      \"when_device_removed\": \"當音頻裝置被移除時\",\n      \"continue_playing\": \"繼續播放\"\n    },\n    \"download\": {\n      \"download_folder\": \"下載文件夾\",\n      \"max_concurrency\": \"最大同時下載數量\",\n      \"default_download_quality\": \"預設下載音質\",\n      \"when_quality_missing\": \"當下載音質缺失時\",\n      \"download_lower_quality_version\": \"下載低音質版本\",\n      \"download_higher_quality_version\": \"下載高音質版本\"\n    },\n    \"lyric\": {\n      \"enable_status_bar_lyric\": \"啟用狀態欄歌詞\",\n      \"enable_desktop_lyric\": \"啟用桌面歌詞\",\n      \"lock_desktop_lyric\": \"鎖定桌面歌詞\",\n      \"font\": \"字體\",\n      \"font_size\": \"字體大小\",\n      \"font_color\": \"字體顏色\",\n      \"stroke_color\": \"描邊顏色\"\n    },\n    \"plugin\": {\n      \"auto_update_plugin\": \"啟動應用時自動更新插件\",\n      \"not_check_plugin_version\": \"安裝插件時不檢查版本\"\n    },\n    \"short_cut\": {\n      \"enable_local\": \"啟用應用內快捷鍵\",\n      \"enable_global\": \"啟用全局快捷鍵\",\n      \"ability\": \"功能\",\n      \"local_short_cut\": \"應用內快捷鍵\",\n      \"global_short_cut\": \"全局快捷鍵\",\n      \"play/pause\": \"播放/暫停\",\n      \"skip-next\": \"下一首\",\n      \"skip-previous\": \"上一首\",\n      \"volume-up\": \"音量增加\",\n      \"volume-down\": \"音量減少\",\n      \"toggle-desktop-lyric\": \"開啟/關閉桌面歌詞\",\n      \"like/dislike\": \"喜歡/不喜歡當前歌曲\",\n      \"no_short_cut\": \"無\",\n      \"toggle-main-window-visible\": \"顯示/隱藏主窗口\"\n    },\n    \"network\": {\n      \"host\": \"主機\",\n      \"port\": \"端口\",\n      \"username\": \"用戶名\",\n      \"password\": \"密碼\",\n      \"local_cache\": \"本地緩存：{{cacheSize}}\",\n      \"clear_cache\": \"清除緩存\",\n      \"enable_network_proxy\": \"啟用網絡代理\"\n    },\n    \"backup\": {\n      \"resume_mode_append\": \"附加到現有清單後\",\n      \"resume_mode_overwrite\": \"覆蓋現有清單\",\n      \"backup_by_file\": \"通過文件備份\",\n      \"musicfree_backup_file\": \"MusicFree 備份文件\",\n      \"backup_to\": \"備份到...\",\n      \"backup_by_webdav\": \"WebDAV 備份\",\n      \"backup_success\": \"備份成功~\",\n      \"backup_fail\": \"備份失敗：{{reason}}\",\n      \"resume_success\": \"恢復成功~\",\n      \"resume_fail\": \"恢復失敗：{{reason}}\",\n      \"backup_music_sheet\": \"備份歌單\",\n      \"resume_music_sheet\": \"恢復歌單\",\n      \"webdav_server_url\": \"URL\",\n      \"username\": \"用戶名\",\n      \"password\": \"密碼\",\n      \"webdav_data_not_complete\": \"URL、用戶名和密碼不能為空\",\n      \"webdav_backup_file_not_exist\": \"備份文件不存在\"\n    },\n    \"about\": {\n      \"current_version\": \"當前版本：{{version}}\",\n      \"already_latest\": \"已是最新版本！\",\n      \"check_update\": \"檢查更新\",\n      \"software_author\": \"軟件作者：\",\n      \"open_source_declaration\": \"源碼：軟件是基於 AGPL3.0 授權的開源軟件。<Github>Github 地址</Github> <Gitee>Gitee 地址</Gitee>\",\n      \"official_site\": \"官方網站\",\n      \"mobile_version\": \"移動版\"\n    }\n  },\n  \"main\": {\n    \"previous_music\": \"上一首歌曲\",\n    \"next_music\": \"下一首歌曲\",\n    \"close_desktop_lyric\": \"關閉桌面歌詞\",\n    \"open_desktop_lyric\": \"開啟桌面歌詞\",\n    \"unlock_desktop_lyric\": \"解鎖桌面歌詞\",\n    \"lock_desktop_lyric\": \"鎖定桌面歌詞\",\n    \"no_playing_music\": \"目前無正在播放的音樂\"\n  },\n  \"theme\": {\n    \"tab_local\": \"本地主題\",\n    \"tab_remote\": \"主題市場\",\n    \"download_and_use\": \"下載並使用\",\n    \"use_theme\": \"使用主題\",\n    \"install_theme\": \"安裝主題\",\n    \"update_theme\": \"更新主題\",\n    \"uninstall_theme\": \"卸載主題\",\n    \"musicfree_theme\": \"MusicFree 主題\",\n    \"all_files\": \"全部檔案\",\n    \"install_theme_success\": \"安裝主題{{name}}成功~\",\n    \"install_theme_fail\": \"安裝主題失敗: {{reason}}\",\n    \"uninstall_theme_success\": \"卸載主題{{name}}成功~\",\n    \"uninstall_theme_fail\": \"卸載主題失敗: {{reason}}\",\n    \"how_to_submit_new_theme\": \"💡如何提交新主題: 主題市場中的主題與 <Github>MusicFreeThemePacks</Github> 倉庫同步，如果需要提交新主題請直接提PR。\",\n    \"load_remote_theme_error\": \"出錯啦...\",\n    \"invalid_theme\": \"主題無效: {{reason}}\"\n  }\n}\n"
  },
  {
    "path": "scripts/feishu-upload.js",
    "content": "const fs = require('fs');\nconst path = require('path');\nconst { Readable } = require('stream');\nconst { Client } = require('@larksuiteoapi/node-sdk');\n\n/**\n * 飞书云空间文件上传工具\n * 支持小文件直接上传和大文件分片上传\n * 包含重试机制和错误处理\n */\nclass FeishuFileUploader {\n    constructor(appId, appSecret, tenantAccessToken = null) {\n        this.client = new Client({\n            appId: appId,\n            appSecret: appSecret,\n            disableTokenCache: true\n        });\n        this.appId = appId;\n        this.appSecret = appSecret;\n        this.tenantAccessToken = tenantAccessToken;\n        this.tokenExpireTime = null;\n        this.maxRetries = 5; // 最大重试次数\n        this.retryDelay = 1000; // 重试延迟（毫秒）\n        this.maxFileSize = 20 * 1024 * 1024; // 20MB - 小文件直接上传的阈值\n        this.chunkSize = 4 * 1024 * 1024; // 4MB - 分片大小\n    }\n\n    /**\n     * 获取tenant_access_token\n     */\n    async getTenantAccessToken() {\n        // 如果token存在且未过期（剩余时间超过5分钟），直接返回\n        if (this.tenantAccessToken && this.tokenExpireTime) {\n            const now = Date.now();\n            const remainingTime = this.tokenExpireTime - now;\n            if (remainingTime > 5 * 60 * 1000) { // 5分钟\n                return this.tenantAccessToken;\n            }\n        }\n\n        console.log('正在获取 tenant_access_token...');\n\n        try {\n            const response = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json; charset=utf-8'\n                },\n                body: JSON.stringify({\n                    app_id: this.appId,\n                    app_secret: this.appSecret\n                })\n            });\n\n            const data = await response.json();\n\n            if (data.code !== 0) {\n                throw new Error(`获取 tenant_access_token 失败: ${data.msg}`);\n            }\n\n            this.tenantAccessToken = data.tenant_access_token;\n            this.tokenExpireTime = Date.now() + (data.expire * 1000);\n\n            console.log('tenant_access_token 获取成功');\n            return this.tenantAccessToken;\n\n        } catch (error) {\n            console.error('获取 tenant_access_token 失败:', error.message);\n            throw error;\n        }\n    }    /**\n     * 计算Adler-32校验和\n     */\n    calculateAdler32(buffer) {\n        const MOD = 65521;\n        let s1 = 1;  // 初始低位累加和\n        let s2 = 0;  // 初始高位累加和\n\n        // 遍历缓冲区中的每个字节\n        for (let i = 0; i < buffer.length; i++) {\n            // 获取无符号字节值 (0-255)\n            const byte = buffer[i];\n\n            // 更新累加值，使用模数防止溢出\n            s1 = (s1 + byte) % MOD;\n            s2 = (s2 + s1) % MOD;\n        }\n\n        // 组合结果: (s2 << 16) | s1\n        const combinedValue = (s2 << 16) | s1;\n\n        // 确保结果是32位无符号整数\n        // 注意：JavaScript 的位操作符返回的是32位有符号整数，所以需要转换\n        const result = combinedValue >>> 0;\n\n        // 返回十进制格式的字符串\n        return result.toString(10);\n    }\n\n    /**\n     * 延迟函数\n     */\n    delay(ms) {\n        return new Promise(resolve => setTimeout(resolve, ms));\n    }\n\n    /**\n     * 重试包装器\n     */\n    async withRetry(operation, operationName) {\n        let lastError;\n\n        for (let attempt = 1; attempt <= this.maxRetries; attempt++) {\n            try {\n                return await operation();\n            } catch (error) {\n                lastError = error;\n\n                // 检查是否是可重试的错误\n                const isRetryableError = this.isRetryableError(error);\n\n                if (!isRetryableError || attempt === this.maxRetries) {\n                    console.error(`${operationName} 失败 (尝试 ${attempt}/${this.maxRetries}):`, error.message);\n                    throw error;\n                }\n\n                const delayTime = this.retryDelay * Math.pow(2, attempt - 1); // 指数退避\n                console.log(`${operationName} 失败 (尝试 ${attempt}/${this.maxRetries}), ${delayTime}ms 后重试...`);\n                await this.delay(delayTime);\n            }\n        }\n\n        throw lastError;\n    }\n\n    /**\n     * 判断是否为可重试的错误\n     */\n    isRetryableError(error) {\n        if (error.response && error.response.data) {\n            const code = error.response.data.code;\n            // 1061045: 频率限制错误，可重试\n            // 1061001: 内部错误，可重试\n            // 1064230: 数据迁移中，可重试\n            return [1061045, 1061001, 1064230].includes(code);\n        }\n\n        // 网络错误等也可以重试\n        return error.code === 'ECONNRESET' ||\n            error.code === 'ETIMEDOUT' ||\n            error.code === 'ENOTFOUND';\n    }    /**\n     * 直接上传小文件\n     */\n    async uploadSmallFile(filePath, fileName, parentNode) {\n        const fileBuffer = fs.readFileSync(filePath);\n        const token = await this.getTenantAccessToken();\n\n        return await this.withRetry(async () => {\n            const response = await this.client.drive.v1.file.upload({\n                data: {\n                    file_name: fileName,\n                    parent_type: 'explorer',\n                    parent_node: parentNode,\n                    size: fileBuffer.length,\n                    file: fileBuffer\n                }\n            }, {\n                headers: {\n                    'Authorization': `Bearer ${token}`\n                }\n            });\n\n            return response.data;\n        }, '小文件上传');\n    }    /**\n     * 分片上传预处理\n     */\n    async uploadPrepare(fileName, parentNode, fileSize) {\n        const token = await this.getTenantAccessToken();\n\n        return await this.withRetry(async () => {\n            const response = await this.client.drive.v1.file.uploadPrepare({\n                data: {\n                    file_name: fileName,\n                    parent_type: 'explorer',\n                    parent_node: parentNode,\n                    size: fileSize\n                }\n            }, {\n                headers: {\n                    'Authorization': `Bearer ${token}`\n                }\n            });\n\n            return response.data;\n        }, '预上传');\n    }/**\n     * 上传单个分片\n     */\n    async uploadPart(uploadId, seq, chunkBuffer) {\n        const checksum = this.calculateAdler32(chunkBuffer);\n        const token = await this.getTenantAccessToken();\n\n        // 创建一个真正的可读流\n        const chunkStream = new Readable({\n            read() { }\n        });\n        chunkStream.push(chunkBuffer);\n        chunkStream.push(null); // 标记流结束        \n        return await this.withRetry(async () => {\n            const response = await this.client.drive.v1.file.uploadPart({\n                data: {\n                    upload_id: uploadId,\n                    seq: seq,\n                    size: chunkBuffer.length,\n                    checksum: checksum,\n                    file: chunkStream\n                }\n            }, {\n                headers: {\n                    'Authorization': `Bearer ${token}`\n                }\n            });\n\n            return response?.data;\n        }, `分片上传 (${seq})`);\n    }\n\n    /**\n     * 完成分片上传\n     */\n    async uploadFinish(uploadId, blockNum) {\n        const token = await this.getTenantAccessToken();\n\n        return await this.withRetry(async () => {\n            const response = await this.client.drive.v1.file.uploadFinish({\n                data: {\n                    upload_id: uploadId,\n                    block_num: blockNum\n                }\n            }, {\n                headers: {\n                    'Authorization': `Bearer ${token}`\n                }\n            });\n\n            return response.data;\n        }, '完成上传');\n    }\n\n    /**\n     * 分片上传大文件\n     */\n    async uploadLargeFile(filePath, fileName, parentNode) {\n        const fileStats = fs.statSync(filePath);\n        const fileSize = fileStats.size;\n\n        console.log(`开始分片上传文件: ${fileName} (${fileSize} bytes)`);\n\n        // 1. 预上传\n        const prepareResult = await this.uploadPrepare(fileName, parentNode, fileSize);\n        const { upload_id, block_size, block_num } = prepareResult;\n\n        console.log(`预上传成功, upload_id: ${upload_id}, 分片数量: ${block_num}`);\n\n        // 2. 分片上传\n        const fileHandle = fs.openSync(filePath, 'r');\n        try {\n            for (let i = 0; i < block_num; i++) {\n                const start = i * block_size;\n                const end = Math.min(start + block_size, fileSize);\n                const chunkSize = end - start;\n\n                const chunkBuffer = Buffer.alloc(chunkSize);\n                fs.readSync(fileHandle, chunkBuffer, 0, chunkSize, start);\n\n                console.log(`上传分片 ${i + 1}/${block_num} (${chunkSize} bytes)`);\n                await this.uploadPart(upload_id, i, chunkBuffer);\n\n                // 避免频率限制，分片之间稍微延迟\n                if (i < block_num - 1) {\n                    await this.delay(200);\n                }\n            }\n        } finally {\n            fs.closeSync(fileHandle);\n        }\n\n        // 3. 完成上传\n        console.log('完成分片上传...');\n        const finishResult = await this.uploadFinish(upload_id, block_num);\n\n        return finishResult;\n    }\n\n    /**\n     * 主上传函数 - 自动选择上传方式\n     */\n    async uploadFile(filePath, fileName, parentNode) {\n        if (!fs.existsSync(filePath)) {\n            throw new Error(`文件不存在: ${filePath}`);\n        }\n\n        const fileStats = fs.statSync(filePath);\n        const fileSize = fileStats.size;\n\n        console.log(`准备上传文件: ${fileName}`);\n        console.log(`文件大小: ${fileSize} bytes`);\n        console.log(`目标文件夹: ${parentNode}`);\n\n        try {\n            let result;\n\n            if (fileSize <= this.maxFileSize) {\n                console.log('使用直接上传方式');\n                result = await this.uploadSmallFile(filePath, fileName, parentNode);\n            } else {\n                console.log('使用分片上传方式');\n                result = await this.uploadLargeFile(filePath, fileName, parentNode);\n            }\n\n            console.log('文件上传成功!');\n            return result;\n\n        } catch (error) {\n            console.error('文件上传失败:', error.message);\n            if (error.response && error.response.data) {\n                console.error('错误详情:', error.response.data);\n            }\n            throw error;\n        }\n    }\n}\n\n/**\n * 主函数 - 从环境变量和命令行参数获取配置\n */\nasync function main() {\n    try {\n        // 从环境变量获取配置\n        const appId = process.env.FEISHU_APP_ID;\n        const appSecret = process.env.FEISHU_APP_SECRET;\n        const parentNode = process.env.FEISHU_PARENT_NODE;\n\n        // 从命令行参数获取文件路径和文件名\n        const args = process.argv.slice(2);\n        if (args.length < 1) {\n            console.error('用法: node feishu-upload.js <文件路径> [上传文件名]');\n            console.error('');\n            console.error('环境变量:');\n            console.error('  FEISHU_APP_ID         - 飞书应用ID');\n            console.error('  FEISHU_APP_SECRET     - 飞书应用密钥');\n            console.error('  FEISHU_PARENT_NODE    - 云空间文件夹token');\n            process.exit(1);\n        }\n\n        const filePath = path.resolve(args[0]);\n        const fileName = args[1] || path.basename(filePath);\n\n        // 验证环境变量\n        if (!appId || !appSecret || !parentNode) {\n            console.error('错误: 缺少必要的环境变量');\n            console.error('请设置: FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_PARENT_NODE');\n            process.exit(1);\n        }\n\n        // 创建上传器实例\n        const uploader = new FeishuFileUploader(appId, appSecret);\n\n        // 执行上传\n        const result = await uploader.uploadFile(filePath, fileName, parentNode);\n\n        console.log('上传结果:', result);\n\n        if (result.file_token) {\n            console.log(`文件上传成功! file_token: ${result.file_token}`);\n        }\n\n    } catch (error) {\n        console.error('上传失败:', error.message);\n        process.exit(1);\n    }\n}\n\n// 如果直接运行此脚本，则执行主函数\nif (require.main === module) {\n    main();\n}\n\n// 导出类以供其他模块使用\nmodule.exports = { FeishuFileUploader };\n"
  },
  {
    "path": "src/common/async-memoize.ts",
    "content": "\nexport default function asyncMemoize<R, T extends (...args: any[]) => Promise<R>>(callback: T): T{\n    let val: R;\n\n    return (async (...args: any[]) => {\n        if(!val) {\n            val = await callback(...args);\n        }\n\n        return val;\n    }) as T;\n}\n"
  },
  {
    "path": "src/common/camel-to-snake.ts",
    "content": "export default function camelToSnake(camelCaseStr: string): string {\n    return camelCaseStr.replace(/([A-Z])/g, \"_$1\").toLowerCase(); // 将整个字符串转换为小写\n}\n"
  },
  {
    "path": "src/common/constant.ts",
    "content": "import { IAppConfig } from \"@/types/app-config\";\nimport { ICommand } from \"@shared/message-bus/type\";\n\nexport const internalDataKey = \"$\";\nexport const internalDataSymbol = Symbol.for(\"internal\");\n// 加入播放列表/歌单的时间\nexport const timeStampSymbol = Symbol.for(\"time-stamp\");\n// 加入播放列表的辅助顺序\nexport const sortIndexSymbol = Symbol.for(\"sort-index\");\n/**\n * 歌曲引用次数\n * TODO: 没必要算引用 如果真有需要直接取异或就可以了\n */\nexport const musicRefSymbol = \"$$ref\";\n\n/** 本地存储路径 */\nexport const localFilePathSymbol = Symbol.for(\"local-file-path\");\nexport const localPluginName = \"本地\";\nexport const localPluginHash = \"本地\";\n\nexport const supportedMediaType = [\n    \"music\",\n    \"album\",\n    \"artist\",\n    \"sheet\",\n] as const;\n\nexport const rem = 13;\n\nexport enum RequestStateCode {\n    /** 空闲 */\n    IDLE = 0b00000000,\n    PENDING_FIRST_PAGE = 0b00000010,\n    // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values\n    LOADING = 0b00000010,\n    /** 检索中 */\n    PENDING_REST_PAGE = 0b00000011,\n    /** 部分结束 */\n    PARTLY_DONE = 0b00000100,\n    /** 全部结束 */\n    FINISHED = 0b0001000,\n    /** 出错了 */\n    ERROR = 0b10000000,\n}\n\n/** 音质列表 */\nexport const qualityKeys: IMusic.IQualityKey[] = [\n    \"low\",\n    \"standard\",\n    \"high\",\n    \"super\",\n];\n\nexport const supportLocalMediaType = [\n    \".mp3\",\n    \".mp4\",\n    \".m4s\",\n    \".flac\",\n    \".wma\",\n    \".wav\",\n    \".m4a\",\n    \".ogg\",\n    \".acc\",\n    \".aac\",\n    // \".ape\",\n    \".opus\",\n];\n\nexport const toastDuration = {\n    short: 1000,\n    long: 2500,\n};\n\nexport const defaultFont = {\n    fullName: \"默认\",\n    family: \"\",\n    postscriptName: \"\",\n    style: \"\",\n};\n\ntype IShortCutKeys = keyof IAppConfig[\"shortCut.shortcuts\"];\nexport const shortCutKeys: IShortCutKeys[] = [\n    \"play/pause\",\n    \"skip-next\",\n    \"skip-previous\",\n    \"volume-up\",\n    \"volume-down\",\n    \"toggle-desktop-lyric\",\n    \"like/dislike\",\n    \"toggle-main-window-visible\",\n];\n\n// 快捷键列表对应的指令\nexport const shortCutKeysCommands: Record<IShortCutKeys, keyof ICommand> =\n{\n    \"play/pause\": \"TogglePlayerState\",\n    \"skip-next\": \"SkipToNext\",\n    \"skip-previous\": \"SkipToPrevious\",\n    \"volume-down\": \"VolumeDown\",\n    \"volume-up\": \"VolumeUp\",\n    \"toggle-desktop-lyric\": \"ToggleDesktopLyric\",\n    \"like/dislike\": \"ToggleFavorite\",\n    \"toggle-main-window-visible\": \"ToggleMainWindowVisible\",\n};\n\n// 主进程的Resource\nexport enum ResourceName {\n    SKIP_LEFT_ICON = \"skip-left.png\",\n    SKIP_RIGHT_ICON = \"skip-right.png\",\n    PAUSE_ICON = \"pause.png\",\n    PLAY_ICON = \"play.png\",\n    DEFAULT_ALBUM_COVER_IMAGE = \"album-cover.jpeg\",\n    LOGO_IMAGE = \"logo.png\",\n\n}\n\n/** 下载状态 */\nexport enum DownloadState {\n    /** 空闲状态 */\n    NONE = \"NONE\",\n    /** 排队等待中 */\n    WAITING = \"WAITING\",\n    /** 下载中 */\n    DOWNLOADING = \"DOWNLOADING\",\n    /** 失败 */\n    ERROR = \"ERROR\",\n    /** 下载完成 */\n    DONE = \"DONE\",\n}\n\n// 主题更新链接\nexport const themePackStoreBaseUrl = [\n    \"https://raw.githubusercontent.com/maotoumao/MusicFreeThemePacks/master/\", //github\n    \"https://cdn.jsdelivr.net/gh/maotoumao/MusicFreeThemePacks@master/\",\n    \"https://dev.azure.com/maotoumao/MusicFree/_apis/git/repositories/MusicFreeThemePacks/items?scopePath=/.publish/publish.json&api-version=6.0\", // azure\n];\n\nexport const appUpdateSources = [\n    \"https://gitee.com/maotoumao/MusicFreeDesktop/raw/master/release/version.json\",\n    \"https://raw.githubusercontent.com/maotoumao/MusicFreeDesktop/master/release/version.json\",\n    \"https://cdn.jsdelivr.net/gh/maotoumao/MusicFreeDesktop@master/release/version.json\",\n];\n\nexport enum TrackPlayerSyncType {\n    SyncPlayerState = \"SyncPlayerState\",\n    MusicChanged = \"MusicChanged\",\n    PlayerStateChanged = \"PlayerStateChanged\",\n    RepeatModeChanged = \"RepeatModeChanged\",\n    LyricChanged = \"LyricChanged\",\n    CurrentLyricChanged = \"CurrentLyricChanged\",\n    ProgressChanged = \"ProgressChanged\",\n}\n\n/** 播放器状态 */\nexport enum PlayerState {\n    /** 无音频 */\n    None,\n    /** 播放中 */\n    Playing,\n    /** 暂停 */\n    Paused,\n    /** 缓冲中 */\n    Buffering,\n}\n\n/** 播放模式 */\nexport enum RepeatMode {\n    /** 随机 */\n    Shuffle = \"shuffle\",\n    /** 播放队列 */\n    Queue = \"queue-repeat\",\n    /** 单曲循环 */\n    Loop = \"loop\",\n}\n\n\n/** 窗口类型 */\nexport enum WindowType {\n    MAIN = \"MAIN\",\n    LYRIC = \"LYRIC\",\n    MINIMODE = \"MINIMODE\",\n}\n\nexport enum WindowRole {\n    MAIN = \"MAIN\",\n    SLAVE = \"SLAVE\",\n}\n\nexport const CommonConst = {\n    /** 新建歌单名称长度限制 */\n    NEW_SHEET_NAME_LENGTH_LIMIT: 120,\n};\n"
  },
  {
    "path": "src/common/debounce.ts",
    "content": "import debounce from \"lodash.debounce\";\n\nexport default function (\n    ...args: Parameters<typeof debounce>\n): ReturnType<typeof debounce> {\n    const [\n        func,\n        wait = 500,\n        options = {\n            leading: true,\n            trailing: false,\n        },\n    ] = args;\n\n    return debounce(func, wait, options);\n}\n"
  },
  {
    "path": "src/common/event-wrapper.ts",
    "content": "import EventEmitter from \"eventemitter3\";\nimport { useEffect } from \"react\";\n\nclass EventWrapper<EventTypes> {\n    private ee: EventEmitter;\n\n    constructor() {\n        this.ee = new EventEmitter();\n    }\n\n    /**\n   * 监听\n   * @param eventName 事件名\n   * @param callBack 回调\n   */\n    on<T extends EventTypes, K extends keyof T & (string | symbol)>(\n        eventName: K,\n        callBack: (payload: T[K]) => void,\n    ) {\n        this.ee.on(eventName, callBack);\n    }\n\n    once<T extends EventTypes, K extends keyof T & (string | symbol)>(\n        eventName: K,\n        callBack: (payload: T[K]) => void,\n    ) {\n        this.ee.once(eventName, callBack);\n    }\n\n    emit<T extends EventTypes, K extends keyof T & (string | symbol)>(\n        eventName: K,\n        payload?: T[K],\n    ) {\n        this.ee.emit(eventName, payload);\n    }\n\n    off<T extends EventTypes, K extends keyof T & (string | symbol)>(\n        eventName: K,\n        callBack: (payload: T[K]) => void,\n    ) {\n        this.ee.off(eventName, callBack);\n    }\n\n    use<T extends EventTypes, K extends keyof T & (string | symbol)>(\n        eventName: K,\n        callBack: (payload: T[K]) => void,\n    ) {\n        useEffect(() => {\n            this.ee.on(eventName, callBack);\n            return () => {\n                this.ee.off(eventName, callBack);\n            };\n        }, []);\n    }\n}\n\nexport default EventWrapper;\n"
  },
  {
    "path": "src/common/file-util.ts",
    "content": "import { ICommonTagsResult, IPicture, parseFile } from \"music-metadata\";\nimport path from \"path\";\nimport { localPluginName, supportLocalMediaType } from \"./constant\";\nimport CryptoJS from \"crypto-js\";\nimport fs from \"fs/promises\";\nimport url from \"url\";\nimport type { BigIntStats, PathLike, StatOptions, Stats } from \"original-fs\";\n\nfunction getB64Picture(picture: IPicture) {\n    return `data:${picture.format};base64,${picture.data.toString(\"base64\")}`;\n}\n\nconst specialEncoding = [\"GB2312\"];\n\nexport async function parseLocalMusicItem(\n    filePath: string,\n): Promise<IMusic.IMusicItem> {\n    const hash = CryptoJS.MD5(filePath).toString();\n    try {\n        const { common = {} as ICommonTagsResult } = await parseFile(filePath);\n\n        const jschardet = await import(\"jschardet\");\n\n        // 检测编码\n        let encoding: string | null = null;\n        let conf = 0;\n        const testItems = [common.title, common.artist, common.album];\n\n        for (const testItem of testItems) {\n            if (!testItem) {\n                continue;\n            }\n            const testResult = jschardet.detect(testItem, {\n                minimumThreshold: 0.4,\n            });\n            if (testResult.confidence > conf) {\n                conf = testResult.confidence;\n                encoding = testResult.encoding;\n            }\n\n            if (conf > 0.9) {\n                break;\n            }\n        }\n\n        if (specialEncoding.includes(encoding)) {\n            const iconv = await import(\"iconv-lite\");\n\n            if (common.title) {\n                common.title = iconv.decode(\n                    common.title as unknown as Buffer,\n                    encoding,\n                );\n            }\n            if (common.artist) {\n                common.artist = iconv.decode(\n                    common.artist as unknown as Buffer,\n                    encoding,\n                );\n            }\n            if (common.artist) {\n                common.album = iconv.decode(\n                    common.album as unknown as Buffer,\n                    encoding,\n                );\n            }\n            if (common.lyrics) {\n                common.lyrics = common.lyrics.map((it) =>\n                    it ? iconv.decode(it as unknown as Buffer, encoding) : \"\",\n                );\n            }\n        }\n\n        return {\n            title: common.title ?? path.parse(filePath).name,\n            artist: common.artist ?? \"未知作者\",\n            artwork: common.picture?.[0]\n                ? getB64Picture(common.picture[0])\n                : undefined,\n            album: common.album ?? \"未知专辑\",\n            url: addFileScheme(filePath),\n            localPath: filePath,\n            platform: localPluginName,\n            id: hash,\n            rawLrc: common.lyrics?.join(\"\"),\n        };\n    } catch (e) {\n        return {\n            title: path.parse(filePath).name || filePath,\n            id: hash,\n            platform: localPluginName,\n            localPath: filePath,\n            url: addFileScheme(filePath),\n            artist: \"未知作者\",\n            album: \"未知专辑\",\n        };\n    }\n}\n\nexport async function parseLocalMusicItemFolder(\n    folderPath: string,\n): Promise<IMusic.IMusicItem[]> {\n    /**\n   * 1. 筛选出符合条件的\n   */\n\n    try {\n        const folderStat = await fs.stat(folderPath);\n        if (folderStat.isDirectory()) {\n            const files = await fs.readdir(folderPath);\n            const validFiles = files.filter((fp) =>\n                supportLocalMediaType.some((postfix) => fp.endsWith(postfix)),\n            );\n            // TODO: 分片\n            return Promise.all(\n                validFiles.map((fp) =>\n                    parseLocalMusicItem(path.resolve(folderPath, fp)),\n                ),\n            );\n        }\n        throw new Error(\"Folder Not Found\");\n    } catch {\n        return [];\n    }\n}\n\nexport function addFileScheme(filePath: string) {\n    return filePath.startsWith(\"file:\")\n        ? filePath\n        : url.pathToFileURL(filePath).toString();\n}\n\nexport function addTailSlash(filePath: string) {\n    return filePath.endsWith(\"/\") || filePath.endsWith(\"\\\\\")\n        ? filePath\n        : filePath + \"/\";\n}\n\nexport async function safeStat(\n    path: PathLike,\n    opts?: StatOptions,\n): Promise<Stats | BigIntStats | null> {\n    try {\n        return await fs.stat(path, opts);\n    } catch {\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/common/get-resource-path.ts",
    "content": "/**\n * 只在主进程中使用 获取资源文件的绝对路径\n * @param resourceName 资源文件名\n * @return 资源文件的绝对路径\n */\n\nimport { app } from \"electron\";\nimport path from \"path\";\n\nconst resPath = app.isPackaged\n    ? path.resolve(process.resourcesPath, \"res\")\n    : path.resolve(__dirname, \"../../res\");\n\nexport default (resourceName: string) => {\n    return path.resolve(resPath, resourceName);\n};\n\n"
  },
  {
    "path": "src/common/index-map.ts",
    "content": "export interface IIndexMap {\n    indexOf: (mediaItem?: IMedia.IMediaBase | null) => number;\n    has: (mediaItem?: IMedia.IMediaBase | null) => boolean;\n    update: (mediaItems?: IMedia.IMediaBase[]) => void;\n}\n\nexport function createIndexMap(mediaItems?: IMedia.IMediaBase[]): IIndexMap {\n    const indexMap: Map<string, Map<string, number>> = new Map();\n\n    update(mediaItems);\n\n    function update(mediaItems?: IMedia.IMediaBase[]) {\n        indexMap.clear();\n        if (!mediaItems) {\n            return;\n        }\n        mediaItems?.forEach((mediaItem, index) => {\n            if (!mediaItem) {\n                return;\n            }\n            const { platform, id } = mediaItem;\n            let idMap = indexMap.get(platform);\n            if (!idMap) {\n                idMap = new Map();\n                indexMap.set(platform, idMap);\n            }\n            idMap.set(id, index);\n        });\n    }\n\n    function indexOf(mediaItem?: IMedia.IMediaBase | null) {\n        if (!mediaItem) {\n            return -1;\n        }\n        return indexMap.get(mediaItem?.platform)?.get(mediaItem?.id) ?? -1;\n    }\n\n    function has(mediaItem?: IMedia.IMediaBase | null) {\n        if (!mediaItem) {\n            return false;\n        }\n        return indexMap.get(mediaItem?.platform)?.has(mediaItem?.id) ?? false;\n    }\n\n    return {\n        update,\n        indexOf,\n        has,\n    };\n}\n"
  },
  {
    "path": "src/common/is-renderer.ts",
    "content": "export function isRenderer() {\n    if (typeof process === \"undefined\" || !process) {\n        // renderer process has no process variable with nodeIntegration off and sandbox on \n        return true;\n    } \n\n    return process.type === \"renderer\";\n}\n"
  },
  {
    "path": "src/common/media-util.ts",
    "content": "import { produce, setAutoFreeze } from \"immer\";\nimport {\n    internalDataKey,\n    localPluginName,\n    qualityKeys,\n    sortIndexSymbol,\n    timeStampSymbol,\n} from \"./constant\";\nsetAutoFreeze(false);\n\nexport function isSameMedia(\n    a?: IMedia.IMediaBase | null,\n    b?: IMedia.IMediaBase | null,\n) {\n    if (a && b) {\n        return a.id === b.id && a.platform === b.platform;\n    }\n    return false;\n}\n\nexport function resetMediaItem<T extends IMedia.IMediaBase>(\n    mediaItem: T,\n    platform?: string,\n    newObj?: boolean,\n): T {\n    // 本地音乐不做处理\n    if (mediaItem.platform === localPluginName || platform === localPluginName) {\n        return newObj ? { ...mediaItem } : mediaItem;\n    }\n    if (!newObj) {\n        mediaItem.platform = platform ?? mediaItem.platform;\n        mediaItem[internalDataKey] = undefined;\n        return mediaItem;\n    } else {\n        return produce(mediaItem, (_) => {\n            _.platform = platform ?? mediaItem.platform;\n            _[internalDataKey] = undefined;\n        });\n    }\n}\n\nexport function getMediaPrimaryKey(mediaItem: IMedia.IUnique) {\n    if (mediaItem) {\n        return `${mediaItem.platform}@${mediaItem.id}`;\n    }\n    return \"invalid@invalid\";\n}\n\nexport function sortByTimestampAndIndex(array: any[], newArray = false) {\n    if (newArray) {\n        array = [...array];\n    }\n    return array.sort((a, b) => {\n        const ts = a[timeStampSymbol] - b[timeStampSymbol];\n        if (ts !== 0) {\n            return ts;\n        }\n        return a[sortIndexSymbol] - b[sortIndexSymbol];\n    });\n}\n\nexport function addSortProperty(\n    mediaItems: IMedia.IMediaBase | IMedia.IMediaBase[],\n) {\n    const now = Date.now();\n    if (Array.isArray(mediaItems)) {\n        mediaItems.forEach((item, index) => {\n            if (!item) {\n                return;\n            }\n            item[timeStampSymbol] = now;\n            item[sortIndexSymbol] = index;\n        });\n    } else {\n        if (!mediaItems) {\n            return;\n        }\n        mediaItems[timeStampSymbol] = now;\n        mediaItems[sortIndexSymbol] = 0;\n    }\n}\n\nexport function flatMediaItem<T extends IMedia.IMediaBase>(mediaItem: T) {\n    if (!mediaItem) {\n        return mediaItem;\n    }\n\n    return {\n        ...mediaItem,\n        ...(mediaItem?.$raw || {}),\n        platform: mediaItem.platform || mediaItem?.$raw?.platform,\n        id: mediaItem.id || mediaItem?.$raw?.id,\n    } as T;\n}\n\nexport function removeInternalProperties<T extends IMedia.IMediaBase>(\n    mediaItem: T,\n) {\n    if (!mediaItem) {\n        return mediaItem;\n    }\n\n    const keys = Object.keys(mediaItem);\n    return keys.reduce((obj, key) => {\n        if (!key.startsWith(\"$\")) {\n            obj[key] = mediaItem[key];\n        }\n        return obj;\n    }, {} as any) as T;\n}\n\n/**\n *  获取音质顺序\n *\n * higher: 优先高音质\n * lower：优先低音质\n */\nexport function getQualityOrder(\n    qualityKey: IMusic.IQualityKey,\n    sort: \"higher\" | \"lower\" | \"skip\",\n) {\n    if (sort === \"skip\") {\n        return [qualityKey];\n    }\n\n    const idx = qualityKeys.indexOf(qualityKey);\n    const left = qualityKeys.slice(0, idx);\n    const right = qualityKeys.slice(idx + 1);\n    if (sort === \"higher\") {\n    /** 优先高音质 */\n        return [qualityKey, ...right, ...left.reverse()];\n    } else {\n    /** 优先低音质 */\n        return [qualityKey, ...left.reverse(), ...right];\n    }\n}\n\n/** 获取内部属性 */\nexport function getInternalData<\n    T extends Record<string, any>,\n    K extends keyof T = keyof T,\n>(mediaItem: IMedia.IMediaBase, internalProp: K): T[K] | null {\n    if (!mediaItem || !mediaItem[internalDataKey]) {\n        return null;\n    }\n    return mediaItem[internalDataKey][internalProp] ?? null;\n}\n\nexport function setInternalData<\n    T extends Record<string, any>,\n    K extends keyof T = keyof T,\n    R extends IMedia.IMediaBase = IMedia.IMediaBase,\n>(mediaItem: R, internalProp: K, value: T[K] | null, newObj = false): R {\n    if (newObj) {\n        return {\n            ...mediaItem,\n            [internalDataKey]: {\n                ...(mediaItem[internalDataKey] ?? {}),\n                [internalProp]: value,\n            },\n        };\n    }\n\n    mediaItem[internalDataKey] = mediaItem[internalDataKey] ?? {};\n    mediaItem[internalDataKey][internalProp] = value;\n    return mediaItem;\n}\n\nexport function toMediaBase(media: IMedia.IMediaBase) {\n    return {\n        platform: media.platform,\n        id: media.id,\n    };\n}\n"
  },
  {
    "path": "src/common/normalize-util.ts",
    "content": "export function normalizeNumberCN(number: number): string {\n    if (number < 10000) {\n        return `${number}`;\n    }\n    number = number / 10000;\n    if (number < 10000) {\n        return `${number.toFixed(number < 1000 ? 1 : 0)}万`;\n    }\n    number = number / 10000;\n    return `${number.toFixed(number < 1000 ? 1 : 0)}亿`;\n}\n\nexport function normalizeNumberEN(number: number): string {\n    if (number < 10000) {\n        return `${number}`;\n    }\n    number = number / 1000;\n    if (number < 1000) {\n        return `${number.toFixed(number < 1000 ? 1 : 0)} K`;\n    }\n    number = number / 1000;\n    if (number < 1000) {\n        return `${number.toFixed(number < 1000 ? 1 : 0)} M`;\n    }\n\n    number = number / 100;\n    return `${number.toFixed(number < 1000 ? 1 : 0)} B`;\n}\n\nexport function normalizeNumber(number: number, en?: boolean): string {\n    const _n = +number;\n    if (isNaN(_n) || !isFinite(_n)) {\n        return \"-\";\n    } else if (isFinite(_n)) {\n        return en ? normalizeNumberEN(_n) : normalizeNumberCN(_n);\n    }\n}\n\nexport function addRandomHash(url: string) {\n    if (url.indexOf(\"#\") === -1) {\n        return `${url}#${Date.now()}`;\n    }\n    return url;\n}\n\n/** url hack */\nexport function encodeUrlHeaders(\n    originalUrl: string,\n    headers?: Record<string, string>,\n) {\n    let formalizedKey: string;\n    const _setHeaders: Record<string, string> = {};\n\n    for (const key in headers) {\n        formalizedKey = key.toLowerCase();\n        _setHeaders[formalizedKey] = headers[key];\n    }\n    const encodedUrl = new URL(originalUrl);\n    encodedUrl.searchParams.set(\n        \"_setHeaders\",\n        encodeURIComponent(JSON.stringify(_setHeaders)),\n    );\n    return encodedUrl.toString();\n}\n\nexport function isBetween(target: number, a: number, b: number) {\n    if (a > b) {\n        return a >= target && target >= b;\n    }\n    return b >= target && target >= a;\n}\n\nexport function isBasicType(val: unknown) {\n    const tp = typeof val;\n    if (\n        tp === \"string\" ||\n    tp === \"boolean\" ||\n    tp === \"number\" ||\n    tp === \"undefined\" ||\n    val === null\n    ) {\n        return true;\n    }\n    return false;\n}\n\nconst fileSizeUnits = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\nexport function normalizeFileSize(bytes: number) {\n    let ptr = 0;\n    while (bytes >= 1024 && ptr < fileSizeUnits.length) {\n        bytes = bytes / 1024;\n        ++ptr;\n    }\n    return `${bytes.toFixed(1)}${fileSizeUnits[ptr]}`;\n}\n"
  },
  {
    "path": "src/common/safe-serialization.ts",
    "content": "export function safeStringify(object: object) {\n    try {\n        return JSON.stringify(object);\n    } catch {\n        return \"\";\n    }\n}\n\nexport function safeParse(str: string) {\n    try {\n        return JSON.parse(str);\n    } catch {\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/common/store.ts",
    "content": "// 数据存储方案\nimport { useEffect, useState } from \"react\";\n\nexport class StateMapper<T> {\n    private getFun: () => T;\n    public cbs: Set<() => void> = new Set([]);\n    constructor(getFun: () => T) {\n        this.getFun = getFun;\n    }\n\n    notify = () => {\n        this.cbs.forEach((_) => _?.());\n    };\n\n    useMappedState = () => {\n        const [_state, _setState] = useState<T>(this.getFun);\n\n        const updateState = () => {\n            _setState(this.getFun());\n        };\n        useEffect(() => {\n            this.cbs.add(updateState);\n            return () => {\n                this.cbs.delete(updateState);\n            };\n        }, []);\n        return _state;\n    };\n}\n\ntype UpdateFunc<T> = (prev: T) => T;\n\nexport default class Store<T> {\n    private value: T;\n    private stateMapper: StateMapper<T>;\n    private valueChangeCbs: Set<(newValue: T, oldValue: T) => void> = new Set([]);\n\n    constructor(initValue: T) {\n        this.value = initValue;\n        this.stateMapper = new StateMapper(this.getValue);\n    }\n\n    public getValue = () => {\n        return this.value;\n    };\n\n    public useValue = () => {\n        return this.stateMapper.useMappedState();\n    };\n\n    public setValue = (value: T | UpdateFunc<T>) => {\n        let newValue: T;\n        if (typeof value === \"function\") {\n            newValue = (value as UpdateFunc<T>)(this.value);\n        } else {\n            newValue = value;\n        }\n        this.valueChangeCbs.forEach((cb) => {\n            cb(newValue, this.value);\n        });\n        this.value = newValue;\n        this.stateMapper.notify();\n    };\n\n    public onValueChange = (cb: (newValue: T, oldValue: T) => void) => {\n        this.valueChangeCbs.add(cb);\n\n        return () => {\n            this.valueChangeCbs.delete(cb);\n        };\n    };\n}\n\nexport function useStore<T>(store: Store<T>) {\n    return [store.useValue(), store.setValue] as const;\n}\n"
  },
  {
    "path": "src/common/thumb-bar-util.ts",
    "content": "/**\n * Thumb Bar Util\n */\n\nimport { BrowserWindow, nativeImage } from \"electron\";\nimport getResourcePath from \"@/common/get-resource-path\";\nimport { t } from \"@shared/i18n/main\";\nimport { ResourceName } from \"@/common/constant\";\nimport asyncMemoize from \"@/common/async-memoize\";\nimport fs from \"fs/promises\";\nimport logger from \"@shared/logger/main\";\nimport axios from \"axios\";\nimport messageBus from \"@shared/message-bus/main\";\n\n/**\n * 设置缩略图按钮\n * @param window 当前窗口\n * @param isPlaying 当前是否正在播放音乐\n */\nfunction setThumbBarButtons(window: BrowserWindow, isPlaying?: boolean) {\n    if (!window) {\n        return;\n    }\n\n    window.setThumbarButtons([\n        {\n            icon: nativeImage.createFromPath(getResourcePath(ResourceName.SKIP_LEFT_ICON)),\n            tooltip: t(\"main.previous_music\"),\n            click() {\n                messageBus.sendCommand(\"SkipToPrevious\");\n            },\n        },\n        {\n            icon: nativeImage.createFromPath(\n                getResourcePath(isPlaying ? ResourceName.PAUSE_ICON : ResourceName.PLAY_ICON),\n            ),\n            tooltip: isPlaying\n                ? t(\"media.music_state_pause\")\n                : t(\"media.music_state_play\"),\n            click() {\n                messageBus.sendCommand(\n                    \"TogglePlayerState\",\n                );\n            },\n        },\n        {\n            icon: nativeImage.createFromPath(getResourcePath(ResourceName.SKIP_RIGHT_ICON)),\n            tooltip: t(\"main.next_music\"),\n            click() {\n                messageBus.sendCommand(\"SkipToNext\");\n            },\n        },\n    ]);\n\n}\n\n\n// 获取默认的图片\nconst getDefaultAlbumCoverImage = asyncMemoize(async () => {\n    return await fs.readFile((getResourcePath(ResourceName.DEFAULT_ALBUM_COVER_IMAGE)));\n});\n\nlet hookedFlag = false;\n\n/**\n * 设置缩略图\n * @param window 窗口\n * @param src 图片url\n */\nasync function setThumbImage(window: BrowserWindow, src: string) {\n    if (!window) {\n        return;\n    }\n\n    // only support windows\n    if (process.platform !== \"win32\") {\n        return;\n    }\n\n    try {\n        const hwnd = window.getNativeWindowHandle().readBigUInt64LE(0);\n\n        const taskBarThumbManager = (await import(\"@native/TaskbarThumbnailManager/TaskbarThumbnailManager.node\")).default;\n\n        if (!hookedFlag) {\n            taskBarThumbManager.config(hwnd);\n            hookedFlag = true;\n        }\n\n        let buffer: Buffer;\n        if (!src) {\n            buffer = await getDefaultAlbumCoverImage();\n        } else if (src.startsWith(\"http\")) {\n            try {\n                buffer = (\n                    await axios.get(src, {\n                        responseType: \"arraybuffer\",\n                    })\n                ).data;\n            } catch {\n                buffer = await getDefaultAlbumCoverImage();\n            }\n        } else if (src.startsWith(\"data:image\")) {\n            buffer = Buffer.from(src.split(\";base64,\").pop(), \"base64\");\n        } else {\n            buffer = await getDefaultAlbumCoverImage();\n        }\n\n        const size = 106;\n\n        const sharp = (await import(\"sharp\")).default;\n        const result = await sharp(buffer)\n            .resize(size, size, {\n                fit: \"cover\",\n            })\n            .png()\n            .ensureAlpha(1)\n            .raw()\n            .toBuffer({\n                resolveWithObject: true,\n            });\n\n        taskBarThumbManager.sendIconicRepresentation(\n            hwnd,\n            {\n                width: size,\n                height: size,\n            },\n            result.data,\n        );\n    } catch (ex) {\n        logger.logError(\"Fail to setThumbImage\", ex);\n    }\n\n\n}\n\n\nconst ThumbBarManager = {\n    setThumbBarButtons,\n    setThumbImage,\n};\n\nexport default ThumbBarManager;\n"
  },
  {
    "path": "src/common/time-util.ts",
    "content": "export function secondsToDuration(seconds: number | string) {\n    if (typeof seconds === \"string\") {\n        return seconds;\n    } else {\n        const sec = seconds % 60;\n        seconds = Math.floor(seconds / 60);\n        const min = seconds % 60;\n        const hour = Math.floor(seconds / 60);\n        const ms = `${min}`.padStart(2, \"0\") + \":\" + `${Math.floor(sec)}`.padStart(2, \"0\");\n        if (hour === 0) {\n            return ms;\n        } else {\n            return `${hour}:${ms}`;\n        }\n    }\n}\n\nexport function delay(millsecond: number) {\n    return new Promise<void>((resolve) => {\n        setTimeout(() => {\n            resolve();\n        }, millsecond);\n    });\n}\n"
  },
  {
    "path": "src/common/unique-map.ts",
    "content": "export interface IUniqueMap {\n    getMap: () => Record<string, Set<string>>;\n    has: (mediaItem?: IMedia.IMediaBase | null) => boolean;\n    add: (mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) => void;\n    remove: (mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) => void;\n}\n\nexport function createUniqueMap(mediaItems?: IMedia.IMediaBase[]): IUniqueMap {\n    const uniqueMap: Record<string, Set<string>> = {};\n\n    mediaItems?.forEach((item) => {\n        add(item);\n    });\n\n    function getMap() {\n        return uniqueMap;\n    }\n\n    function has(mediaItem?: IMedia.IMediaBase | null) {\n        if (!mediaItem) {\n            return false;\n        }\n\n        return uniqueMap[`${mediaItem.platform}`]?.has(`${mediaItem.id}`) || false;\n    }\n\n    function add(mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) {\n        if (!mediaItem) {\n            return;\n        }\n        const _mediaItem = Array.isArray(mediaItem) ? mediaItem : [mediaItem];\n        _mediaItem.forEach((item) => {\n            if (!uniqueMap[`${item.platform}`]) {\n                uniqueMap[`${item.platform}`] = new Set([`${item.id}`]);\n            } else {\n                uniqueMap[`${item.platform}`].add(`${item.id}`);\n            }\n        });\n    }\n\n    function remove(mediaItem?: IMedia.IMediaBase | IMedia.IMediaBase[] | null) {\n        if (!mediaItem) {\n            return;\n        }\n        const _mediaItem = Array.isArray(mediaItem) ? mediaItem : [mediaItem];\n\n        _mediaItem.forEach((item) => {\n            if (uniqueMap[`${item.platform}`]) {\n                uniqueMap[`${item.platform}`].delete(`${item.id}`);\n            }\n        });\n    }\n\n    return {\n        getMap,\n        add,\n        has,\n        remove,\n    };\n}\n"
  },
  {
    "path": "src/common/void-callback.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/no-empty-function\nexport default () => {};\n"
  },
  {
    "path": "src/hooks/useAppConfig.ts",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport { useEffect, useState } from \"react\";\n\n\nexport default function useAppConfig<K extends keyof IAppConfig>(configKey: K): IAppConfig[K] {\n    const [state, setState] = useState<IAppConfig[K]>(AppConfig.getConfig(configKey));\n\n    useEffect(() => {\n        const callback = (patch: IAppConfig, fullConfig: IAppConfig) => {\n            if (configKey in patch) {\n                setState(fullConfig[configKey]);\n            }\n        };\n\n        AppConfig.onConfigUpdate(callback);\n\n        return () => {\n            AppConfig.offConfigUpdate(callback);\n        };\n    }, []);\n\n\n    return state;\n}\n"
  },
  {
    "path": "src/hooks/useLocalFonts.ts",
    "content": "import Store from \"@/common/store\";\nimport { useEffect } from \"react\";\n\nconst fontsStore = new Store<FontData[] | null>(null);\n\nasync function initFonts() {\n    if (fontsStore.getValue()) {\n        return fontsStore.getValue();\n    }\n    try {\n        const allFonts = await window.queryLocalFonts();\n        fontsStore.setValue(allFonts);\n        return allFonts;\n    } catch (e) {\n        console.log(e);\n    }\n    return null;\n}\n\nexport default function useLocalFonts() {\n    useEffect(() => {\n        initFonts();\n    }, []);\n\n    return fontsStore.useValue();\n}\n"
  },
  {
    "path": "src/hooks/useMediaDevices.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useOutputAudioDevices() {\n    const [devices, setDevices] = useState<MediaDeviceInfo[] | null>(null);\n\n    useEffect(() => {\n        navigator.mediaDevices\n            .enumerateDevices()\n            .then((res) => {\n                setDevices(res.filter((item) => item.kind === \"audiooutput\"));\n            })\n            .catch(() => {})\n            .finally(() => {});\n    }, []);\n\n    return devices;\n}\n"
  },
  {
    "path": "src/hooks/useMounted.ts",
    "content": "import { useEffect, useRef } from \"react\";\n\nexport default function useMounted(){\n    const isMounted = useRef(false);\n\n    useEffect(() => {\n        isMounted.current = true;\n\n        return () => {\n            isMounted.current = false;\n        };\n    }, []);\n\n    return isMounted;\n}"
  },
  {
    "path": "src/hooks/useStateRef.ts",
    "content": "import { useRef, useState } from \"react\";\n\nexport default function useStateRef<T>(initValue: T) {\n    const [state, setState] = useState(initValue);\n    const ref = useRef(initValue);\n\n    ref.current = state;\n\n    return [state, setState, ref] as const;\n}\n"
  },
  {
    "path": "src/hooks/useVirtualList.ts",
    "content": "import {\n    MutableRefObject,\n    useCallback,\n    useEffect,\n    useRef,\n    useState,\n} from \"react\";\nimport throttle from \"lodash.throttle\";\n\ninterface IVirtualListProps<T> {\n    /** 滚动的容器 */\n    getScrollElement?: () => HTMLElement;\n    /** 滚动容器的query */\n    scrollElementQuery?: string;\n    /** 元素高度和列表高度 */\n    estimateItemHeight: number;\n\n    /** 数据 */\n    data: T[];\n    /** 渲染数目 */\n    renderCount?: number;\n    /** 虚拟列表失效时的渲染数目 */\n    fallbackRenderCount?: number;\n    /** 偏移高度 */\n    offsetHeight?: number | (() => number);\n}\n\ninterface IVirtualItem<T> {\n    /** 偏移 */\n    top: number;\n    /** 下标 */\n    rowIndex: number;\n    /** 数据 */\n    dataItem: T;\n}\n\nexport default function useVirtualList<T>(props: IVirtualListProps<T>) {\n    const {\n        estimateItemHeight,\n        data,\n        renderCount = 40,\n        fallbackRenderCount = -1,\n        getScrollElement,\n        scrollElementQuery,\n        offsetHeight = 0,\n    } = props;\n    const dataRef = useRef(data);\n    dataRef.current = data;\n\n    const [virtualItems, setVirtualItems] = useState<IVirtualItem<T>[]>([]);\n    const [totalHeight, setTotalHeight] = useState<number>(\n        data.length * estimateItemHeight,\n    );\n\n    const scrollElementRef = useRef<HTMLElement>();\n\n    const scrollHandler = useCallback(\n        throttle(\n            () => {\n                const scrollTop =\n          (scrollElementRef.current?.scrollTop ?? 0) -\n          (typeof offsetHeight === \"number\" ? offsetHeight : offsetHeight());\n                const realData = dataRef.current;\n                const estimizeStartIndex = Math.floor(scrollTop / estimateItemHeight);\n                const startIndex = Math.max(\n                    estimizeStartIndex - (estimizeStartIndex % 2 === 1 ? 3 : 2),\n                    0,\n                );\n\n                setVirtualItems(\n                    realData\n                        .slice(\n                            startIndex,\n                            startIndex +\n                (scrollElementRef.current\n                    ? renderCount\n                    : fallbackRenderCount < 0\n                        ? realData.length\n                        : fallbackRenderCount),\n                        )\n                        .map((item, index) => ({\n                            rowIndex: startIndex + index,\n                            dataItem: item,\n                            top: (startIndex + index) * estimateItemHeight,\n                        })),\n                );\n            },\n            32,\n            {\n                trailing: true,\n                leading: true,\n            },\n        ),\n        [],\n    );\n\n    useEffect(() => {\n        setTotalHeight(data.length * estimateItemHeight);\n        scrollHandler();\n    }, [data]);\n\n    useEffect(() => {\n        if (!scrollElementRef.current) {\n            scrollElementRef.current = getScrollElement\n                ? getScrollElement()\n                : document.querySelector(scrollElementQuery);\n        }\n        if (scrollElementRef.current) {\n            scrollElementRef.current.addEventListener(\"scroll\", scrollHandler);\n        }\n\n        return () => {\n            scrollElementRef.current?.removeEventListener?.(\"scroll\", scrollHandler);\n            scrollElementRef.current = null;\n        };\n    }, []);\n\n    function setScrollElement(scrollElement: HTMLElement) {\n        scrollElementRef.current?.removeEventListener(\"scroll\", scrollHandler);\n        scrollElementRef.current = scrollElement;\n        if (scrollElement) {\n            scrollElement.addEventListener(\"scroll\", scrollHandler);\n            scrollHandler();\n        }\n    }\n\n    function scrollToIndex(index: number, behavior?: ScrollBehavior) {\n        scrollElementRef.current.scrollTo({\n            top:\n        (typeof offsetHeight === \"number\" ? offsetHeight : offsetHeight()) +\n        estimateItemHeight * index,\n            behavior,\n        });\n    }\n\n    return {\n        virtualItems,\n        totalHeight,\n        startTop: virtualItems[0]?.top ?? 0,\n        setScrollElement,\n        scrollToIndex,\n    };\n}\n"
  },
  {
    "path": "src/main/deep-link/index.ts",
    "content": "import { supportLocalMediaType } from \"@/common/constant\";\nimport { parseLocalMusicItem, safeStat } from \"@/common/file-util\";\nimport PluginManager from \"@shared/plugin-manager/main\";\nimport voidCallback from \"@/common/void-callback\";\nimport messageBus from \"@shared/message-bus/main\";\n\nexport function handleDeepLink(url: string) {\n    if (!url) {\n        return;\n    }\n\n    try {\n        const urlObj = new URL(url);\n        if (urlObj.protocol === \"musicfree:\") {\n            handleMusicFreeScheme(urlObj);\n        }\n    } catch {\n        // pass\n    }\n}\n\nasync function handleMusicFreeScheme(url: URL) {\n    const hostname = url.hostname;\n    if (hostname === \"install\") {\n        try {\n            const pluginUrlStr =\n                url.pathname.slice(1) || url.searchParams.get(\"plugin\");\n            const pluginUrls = pluginUrlStr.split(\",\").map(decodeURIComponent);\n            await Promise.all(\n                pluginUrls.map((it) => PluginManager.installPluginFromRemoteUrl(it).catch(voidCallback)),\n            );\n        } catch {\n            // pass\n        }\n    }\n}\n\nasync function handleBareUrl(url: string) {\n    try {\n        if (\n            (await safeStat(url)).isFile() &&\n            supportLocalMediaType.some((postfix) => url.endsWith(postfix))\n        ) {\n            const musicItem = await parseLocalMusicItem(url);\n            messageBus.sendCommand(\"PlayMusic\", musicItem);\n        }\n    } catch {\n    }\n}\n"
  },
  {
    "path": "src/main/index.ts",
    "content": "import { app, BrowserWindow, globalShortcut } from \"electron\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { setAutoFreeze } from \"immer\";\nimport { setupGlobalContext } from \"@/shared/global-context/main\";\nimport { setupI18n } from \"@/shared/i18n/main\";\nimport { handleDeepLink } from \"./deep-link\";\nimport logger from \"@shared/logger/main\";\nimport { PlayerState } from \"@/common/constant\";\nimport ThumbBarUtil from \"@/common/thumb-bar-util\";\nimport windowManager from \"@main/window-manager\";\nimport AppConfig from \"@shared/app-config/main\";\nimport TrayManager from \"@main/tray-manager\";\nimport WindowDrag from \"@shared/window-drag/main\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport axios from \"axios\";\nimport { HttpsProxyAgent } from \"https-proxy-agent\";\nimport PluginManager from \"@shared/plugin-manager/main\";\nimport ServiceManager from \"@shared/service-manager/main\";\nimport utils from \"@shared/utils/main\";\nimport messageBus from \"@shared/message-bus/main\";\nimport shortCut from \"@shared/short-cut/main\";\nimport voidCallback from \"@/common/void-callback\";\n\n// portable\nif (process.platform === \"win32\") {\n    try {\n        const appPath = app.getPath(\"exe\");\n        const portablePath = path.resolve(appPath, \"../portable\");\n        const portableFolderStat = fs.statSync(portablePath);\n        if (portableFolderStat.isDirectory()) {\n            const appPathNames = [\"appData\", \"userData\"];\n            appPathNames.forEach((it) => {\n                app.setPath(it, path.resolve(portablePath, it));\n            });\n        }\n    } catch (e) {\n        // pass\n    }\n}\n\nsetAutoFreeze(false);\n\n\nif (process.defaultApp) {\n    if (process.argv.length >= 2) {\n        app.setAsDefaultProtocolClient(\"musicfree\", process.execPath, [\n            path.resolve(process.argv[1]),\n        ]);\n    }\n} else {\n    app.setAsDefaultProtocolClient(\"musicfree\");\n}\n\n// Quit when all windows are closed, except on macOS. There, it's common\n// for applications and their menu bar to stay active until the user quits\n// explicitly with Cmd + Q.\napp.on(\"window-all-closed\", () => {\n    if (process.platform !== \"darwin\") {\n        app.quit();\n    }\n});\n\napp.on(\"activate\", () => {\n    // On OS X it's common to re-create a window in the app when the\n    // dock icon is clicked and there are no other windows open.\n    if (BrowserWindow.getAllWindows().length === 0) {\n        windowManager.showMainWindow();\n    }\n});\n\nif (!app.requestSingleInstanceLock()) {\n    app.exit(0);\n}\n\napp.on(\"second-instance\", (_evt, commandLine) => {\n    if (windowManager.mainWindow) {\n        windowManager.showMainWindow();\n    }\n\n    if (process.platform !== \"darwin\") {\n        handleDeepLink(commandLine.pop());\n    }\n});\n\napp.on(\"open-url\", (_evt, url) => {\n    handleDeepLink(url);\n});\n\napp.on(\"will-quit\", () => {\n    globalShortcut.unregisterAll();\n});\n\n// In this file you can include the rest of your app's specific main process\n// code. You can also put them in separate files and import them here.\napp.whenReady().then(async () => {\n    logger.logPerf(\"App Ready\");\n    setupGlobalContext();\n    await AppConfig.setup(windowManager);\n\n    await setupI18n({\n        getDefaultLang() {\n            return AppConfig.getConfig(\"normal.language\");\n        },\n        onLanguageChanged(lang) {\n            AppConfig.setConfig({\n                \"normal.language\": lang,\n            });\n            if (process.platform === \"win32\") {\n\n                ThumbBarUtil.setThumbBarButtons(windowManager.mainWindow, messageBus.getAppState().playerState === PlayerState.Playing);\n            }\n        },\n    });\n    utils.setup(windowManager);\n    PluginManager.setup(windowManager);\n    TrayManager.setup(windowManager);\n    WindowDrag.setup();\n    shortCut.setup().then(voidCallback);\n    logger.logPerf(\"Create Main Window\");\n    // Setup message bus & app state\n    messageBus.onAppStateChange((_, patch) => {\n        if (\"musicItem\" in patch) {\n            TrayManager.buildTrayMenu();\n            const musicItem = patch.musicItem;\n            const mainWindow = windowManager.mainWindow;\n\n            if (mainWindow) {\n                const thumbStyle = AppConfig.getConfig(\"normal.taskbarThumb\");\n                if (process.platform === \"win32\" && thumbStyle === \"artwork\") {\n                    ThumbBarUtil.setThumbImage(mainWindow, musicItem?.artwork);\n                }\n                if (musicItem) {\n                    mainWindow.setTitle(\n                        musicItem.title + (musicItem.artist ? ` - ${musicItem.artist}` : \"\"),\n                    );\n                } else {\n                    mainWindow.setTitle(app.name);\n                }\n            }\n        } else if (\"playerState\" in patch) {\n            TrayManager.buildTrayMenu();\n            const playerState = patch.playerState;\n\n            if (process.platform === \"win32\") {\n                ThumbBarUtil.setThumbBarButtons(windowManager.mainWindow, playerState === PlayerState.Playing);\n            }\n        } else if (\"repeatMode\" in patch) {\n            TrayManager.buildTrayMenu();\n        } else if (\"lyricText\" in patch && process.platform === \"darwin\") {\n            if (AppConfig.getConfig(\"lyric.enableStatusBarLyric\")) {\n                TrayManager.setTitle(patch.lyricText);\n            } else {\n                TrayManager.setTitle(\"\");\n            }\n        }\n    });\n\n    messageBus.setup(windowManager);\n\n    windowManager.showMainWindow();\n\n    bootstrap();\n\n});\n\nasync function bootstrap() {\n    ServiceManager.setup(windowManager);\n\n    const downloadPath = AppConfig.getConfig(\"download.path\");\n    if (!downloadPath) {\n        AppConfig.setConfig({\n            \"download.path\": app.getPath(\"downloads\"),\n        });\n    }\n\n    const minimodeEnabled = AppConfig.getConfig(\"private.minimode\");\n\n    if (minimodeEnabled) {\n        windowManager.showMiniModeWindow();\n    }\n\n    /** 一些初始化设置 */\n    // 初始化桌面歌词\n    const desktopLyricEnabled = AppConfig.getConfig(\"lyric.enableDesktopLyric\");\n\n    if (desktopLyricEnabled) {\n        windowManager.showLyricWindow();\n    }\n\n    AppConfig.onConfigUpdated((patch) => {\n        // 桌面歌词锁定状态\n        if (\"lyric.lockLyric\" in patch) {\n            const lyricWindow = windowManager.lyricWindow;\n            const lockState = patch[\"lyric.lockLyric\"];\n\n            if (!lyricWindow) {\n                return;\n            }\n            if (lockState) {\n                lyricWindow.setIgnoreMouseEvents(true, {\n                    forward: true,\n                });\n            } else {\n                lyricWindow.setIgnoreMouseEvents(false);\n            }\n        }\n        if (\"shortCut.enableGlobal\" in patch) {\n            const enableGlobal = patch[\"shortCut.enableGlobal\"];\n            if (enableGlobal) {\n                shortCut.registerAllGlobalShortCuts();\n            } else {\n                shortCut.unregisterAllGlobalShortCuts();\n            }\n        }\n    });\n\n\n    // 初始化代理\n    const proxyConfigKeys: Array<keyof IAppConfig> = [\n        \"network.proxy.enabled\",\n        \"network.proxy.host\",\n        \"network.proxy.port\",\n        \"network.proxy.username\",\n        \"network.proxy.password\",\n    ];\n\n    AppConfig.onConfigUpdated((patch, config) => {\n        let proxyUpdated = false;\n        for (const proxyConfigKey of proxyConfigKeys) {\n            if (proxyConfigKey in patch) {\n                proxyUpdated = true;\n                break;\n            }\n        }\n\n        if (proxyUpdated) {\n            if (config[\"network.proxy.enabled\"]) {\n                handleProxy(true, config[\"network.proxy.host\"], config[\"network.proxy.port\"], config[\"network.proxy.username\"], config[\"network.proxy.password\"]);\n            } else {\n                handleProxy(false);\n            }\n        }\n    });\n\n    handleProxy(\n        AppConfig.getConfig(\"network.proxy.enabled\"),\n        AppConfig.getConfig(\"network.proxy.host\"),\n        AppConfig.getConfig(\"network.proxy.port\"),\n        AppConfig.getConfig(\"network.proxy.username\"),\n        AppConfig.getConfig(\"network.proxy.password\"),\n    );\n\n\n}\n\n\nfunction handleProxy(enabled: boolean, host?: string | null, port?: string | null, username?: string | null, password?: string | null) {\n    try {\n        if (!enabled) {\n            axios.defaults.httpAgent = undefined;\n            axios.defaults.httpsAgent = undefined;\n        } else if (host) {\n            const proxyUrl = new URL(host);\n            proxyUrl.port = port;\n            proxyUrl.username = username;\n            proxyUrl.password = password;\n            const agent = new HttpsProxyAgent(proxyUrl);\n\n            axios.defaults.httpAgent = agent;\n            axios.defaults.httpsAgent = agent;\n        } else {\n            throw new Error(\"Unknown Host\");\n        }\n    } catch (e) {\n        axios.defaults.httpAgent = undefined;\n        axios.defaults.httpsAgent = undefined;\n    }\n}\n"
  },
  {
    "path": "src/main/native_modules/TaskbarThumbnailManager/TaskbarThumbnailManager.node.d.ts",
    "content": "declare module \"@native/TaskbarThumbnailManager/TaskbarThumbnailManager.node\" {\n    interface ISize {\n        width: number;\n        height: number;\n    }\n\n    function config(hWnd: bigint): void; \n    function sendIconicRepresentation(hWnd: bigint, size: ISize, buf: Buffer);\n    function sendLivePreviewBitmap(hWnd: bigint, size: ISize, buf: Buffer);\n}"
  },
  {
    "path": "src/main/tray-manager/index.ts",
    "content": "import { app, Menu, MenuItem, MenuItemConstructorOptions, nativeImage, Tray } from \"electron\";\nimport { t } from \"@shared/i18n/main\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport getResourcePath from \"@/common/get-resource-path\";\nimport { PlayerState, RepeatMode, ResourceName } from \"@/common/constant\";\nimport AppConfig from \"@shared/app-config/main\";\nimport windowManager from \"@main/window-manager\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport messageBus from \"@shared/message-bus/main\";\n\nif (process.platform === \"darwin\") {\n    Menu.setApplicationMenu(\n        Menu.buildFromTemplate([\n            {\n                label: app.getName(),\n                submenu: [\n                    {\n                        label: t(\"common.about\"),\n                        role: \"about\",\n                    },\n                    {\n                        label: t(\"common.exit\"),\n                        click() {\n                            app.quit();\n                        },\n                    },\n                ],\n            },\n            {\n                label: t(\"common.edit\"),\n                submenu: [\n                    {\n                        label: t(\"common.undo\"),\n                        accelerator: \"Command+Z\",\n                        role: \"undo\",\n                    },\n                    {\n                        label: t(\"common.redo\"),\n                        accelerator: \"Shift+Command+Z\",\n                        role: \"redo\",\n                    },\n                    { type: \"separator\" },\n                    { label: t(\"common.cut\"), accelerator: \"Command+X\", role: \"cut\" },\n                    { label: t(\"common.copy\"), accelerator: \"Command+C\", role: \"copy\" },\n                    { label: t(\"common.cut\"), accelerator: \"Command+V\", role: \"paste\" },\n                    { type: \"separator\" },\n                    {\n                        label: t(\"common.select_all\"),\n                        accelerator: \"Command+A\",\n                        role: \"selectAll\",\n                    },\n                ],\n            },\n        ]),\n    );\n} else {\n    Menu.setApplicationMenu(null);\n}\n\nclass TrayManager {\n    private static trayInstance: Tray | null = null;\n    private windowManager: IWindowManager;\n\n    private observedKey: Array<keyof IAppConfig> = [\n        \"lyric.lockLyric\",\n        \"lyric.enableDesktopLyric\",\n        \"normal.language\",\n    ];\n\n    public setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n        const tray = new Tray(\n            nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)).resize({\n                width: 32,\n                height: 32,\n            }),\n        );\n\n        if (process.platform === \"linux\") {\n            tray.on(\"click\", () => {\n                windowManager.showMainWindow();\n            });\n        } else {\n            tray.on(\"double-click\", () => {\n                windowManager.showMainWindow();\n            });\n        }\n\n        let debugClickCount = 0;\n        let debugClickTime = 0;\n\n        const debugModeHandler = () => {\n            const now = Date.now();\n            if (now - debugClickTime < 500) {\n                debugClickCount++;\n                debugClickTime = now;\n                if (debugClickCount === 5) {\n                    windowManager.getAllWindows().forEach(win => {\n                        win?.webContents?.openDevTools({\n                            mode: \"undocked\",\n                        });\n                    });\n                }\n            } else {\n                debugClickCount = 1;\n                debugClickTime = Date.now();\n            }\n        };\n\n        tray.on(\"click\", debugModeHandler);\n\n        // 配置变化时更新菜单\n        AppConfig.onConfigUpdated((changedConfig) => {\n            for (const k of this.observedKey) {\n                if (k in changedConfig) {\n                    this.buildTrayMenu();\n                    return;\n                }\n            }\n        });\n\n        TrayManager.trayInstance = tray;\n        this.buildTrayMenu();\n    }\n\n    private openMusicDetail() {\n        this.windowManager.showMainWindow();\n        messageBus.sendCommand(\"OpenMusicDetailPage\");\n    }\n\n    public async buildTrayMenu() {\n        if (!TrayManager.trayInstance) {\n            return;\n        }\n        const ctxMenu: Array<MenuItemConstructorOptions | MenuItem> = [];\n\n        const tray = TrayManager.trayInstance;\n\n        /********* 音乐信息 **********/\n        const { musicItem, playerState, repeatMode } =\n            messageBus.getAppState();\n        // 更新一下tooltip\n        if (musicItem) {\n            tray.setToolTip(\n                `${musicItem.title ?? t(\"media.unknown_title\")}${musicItem.artist ? ` - ${musicItem.artist}` : \"\"\n                }`,\n            );\n        } else {\n            tray.setToolTip(\"MusicFree\");\n        }\n        if (musicItem) {\n            const fullName = `${musicItem.title ?? t(\"media.unknown_title\")}${musicItem.artist ? ` - ${musicItem.artist}` : \"\"\n            }`;\n            ctxMenu.push(\n                {\n                    label: fullName.length > 12 ? fullName.slice(0, 12) + \"...\" : fullName,\n                    click: this.openMusicDetail.bind(this),\n                },\n                {\n                    label: `${t(\"media.media_platform\")}: ${musicItem.platform}`,\n                    click: this.openMusicDetail.bind(this),\n                },\n            );\n        } else {\n            ctxMenu.push({\n                label: t(\"main.no_playing_music\"),\n                enabled: false,\n            });\n        }\n\n        ctxMenu.push(\n            {\n                label: musicItem\n                    ? playerState === PlayerState.Playing\n                        ? t(\"media.music_state_pause\")\n                        : t(\"media.music_state_play\")\n                    : t(\"media.music_state_play_or_pause\"),\n                enabled: !!musicItem,\n                click() {\n                    if (!musicItem) {\n                        return;\n                    }\n                    messageBus.sendCommand(\"TogglePlayerState\");\n                },\n            },\n            {\n                label: t(\"main.previous_music\"),\n                enabled: !!musicItem,\n                click() {\n                    messageBus.sendCommand(\"SkipToPrevious\");\n                },\n            },\n            {\n                label: t(\"main.next_music\"),\n                enabled: !!musicItem,\n                click() {\n                    messageBus.sendCommand(\"SkipToNext\");\n                },\n            },\n        );\n\n        ctxMenu.push({\n            label: t(\"media.music_repeat_mode\"),\n            type: \"submenu\",\n            submenu: Menu.buildFromTemplate([\n                {\n                    label: t(\"media.music_repeat_mode_loop\"),\n                    id: RepeatMode.Loop,\n                    type: \"radio\",\n                    checked: repeatMode === RepeatMode.Loop,\n                    click() {\n                        messageBus.sendCommand(\"SetRepeatMode\", RepeatMode.Loop);\n                    },\n                },\n                {\n                    label: t(\"media.music_repeat_mode_queue\"),\n                    id: RepeatMode.Queue,\n                    type: \"radio\",\n                    checked: repeatMode === RepeatMode.Queue,\n                    click() {\n                        messageBus.sendCommand(\"SetRepeatMode\", RepeatMode.Queue);\n                    },\n                },\n                {\n                    label: t(\"media.music_repeat_mode_shuffle\"),\n                    id: RepeatMode.Shuffle,\n                    type: \"radio\",\n                    checked: repeatMode === RepeatMode.Shuffle,\n                    click() {\n                        messageBus.sendCommand(\"SetRepeatMode\", RepeatMode.Shuffle);\n                    },\n                },\n            ]),\n        });\n\n        ctxMenu.push({\n            type: \"separator\",\n        });\n        /** TODO: 桌面歌词 */\n        // const lyricConfig = await getAppConfigPath(\"lyric\");\n        // if (lyricConfig?.enableDesktopLyric) {\n        //     ctxMenu.push({\n        //         label: t(\"main.close_desktop_lyric\"),\n        //         click() {\n        //             setLyricWindow(false);\n        //         },\n        //     });\n        // } else {\n        //     ctxMenu.push({\n        //         label: t(\"main.open_desktop_lyric\"),\n        //         click() {\n        //             setLyricWindow(true);\n        //         },\n        //     });\n        // }\n        //\n        // if (lyricConfig?.lockLyric) {\n        //     ctxMenu.push({\n        //         label: t(\"main.unlock_desktop_lyric\"),\n        //         click() {\n        //             setDesktopLyricLock(false);\n        //         },\n        //     });\n        // } else {\n        //     ctxMenu.push({\n        //         label: t(\"main.lock_desktop_lyric\"),\n        //         click() {\n        //             setDesktopLyricLock(true);\n        //         },\n        //     });\n        // }\n\n        ctxMenu.push({\n            type: \"separator\",\n        });\n        /********* 其他操作 **********/\n        ctxMenu.push({\n            label: t(\"app_header.settings\"),\n            click() {\n                windowManager.showMainWindow();\n                messageBus.sendCommand(\"Navigate\", \"/main/setting\");\n            },\n        });\n        ctxMenu.push({\n            label: t(\"common.exit\"),\n            role: process.platform === \"win32\" ? undefined : \"quit\",\n            click() {\n                windowManager.mainWindow?.removeAllListeners?.();\n                app.exit(0);\n            },\n        });\n\n        TrayManager.trayInstance.setContextMenu(Menu.buildFromTemplate(ctxMenu));\n    }\n\n    public setTitle(title: string) {\n        if (!title || !title.length) {\n            TrayManager.trayInstance?.setTitle(\"\");\n            return;\n        }\n        if (title.length > 7) {\n            TrayManager.trayInstance?.setTitle(\" \" + title.slice(0) + \"...\");\n        } else {\n            TrayManager.trayInstance?.setTitle(\" \" + title);\n        }\n    }\n\n}\n\nexport default new TrayManager();\n"
  },
  {
    "path": "src/main/window-manager/index.ts",
    "content": "import { app, BrowserWindow, nativeImage, screen } from \"electron\";\nimport getResourcePath from \"@/common/get-resource-path\";\nimport { IWindowEvents, IWindowManager } from \"@/types/main/window-manager\";\nimport { localPluginName, PlayerState, ResourceName } from \"@/common/constant\";\nimport voidCallback from \"@/common/void-callback\";\nimport ThumbBarUtil from \"@/common/thumb-bar-util\";\nimport EventEmitter from \"eventemitter3\";\nimport WindowDrag from \"@shared/window-drag/main\";\nimport AppConfig from \"@shared/app-config/main\";\nimport messageBus from \"@shared/message-bus/main\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport debounce from \"@/common/debounce\";\n\n\n// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack\n// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on\n// whether you're running in development or production).\n\ndeclare const MAIN_WINDOW_WEBPACK_ENTRY: string;\ndeclare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;\ndeclare const LRC_WINDOW_WEBPACK_ENTRY: string;\ndeclare const LRC_WINDOW_PRELOAD_WEBPACK_ENTRY: string;\ndeclare const MINIMODE_WINDOW_WEBPACK_ENTRY: string;\ndeclare const MINIMODE_WINDOW_PRELOAD_WEBPACK_ENTRY: string;\n\n\nclass WindowManager implements IWindowManager {\n    private static mainWindow: BrowserWindow | null = null;\n    private static lrcWindow: BrowserWindow | null = null;\n    private static miniModeWindow: BrowserWindow | null = null;\n\n    private ee: EventEmitter = new EventEmitter();\n\n    getMainWindow(): BrowserWindow {\n        return WindowManager.mainWindow;\n    }\n\n    get mainWindow() {\n        return WindowManager.mainWindow;\n    }\n\n    get lyricWindow() {\n        return WindowManager.lrcWindow;\n    }\n\n    get miniModeWindow() {\n        return WindowManager.miniModeWindow;\n    }\n\n    getExtensionWindows(): BrowserWindow[] {\n        const extWindows = [];\n        if (WindowManager.lrcWindow) {\n            extWindows.push(WindowManager.lrcWindow);\n        }\n        if (WindowManager.miniModeWindow) {\n            extWindows.push(WindowManager.miniModeWindow);\n        }\n        return extWindows;\n    }\n\n    getAllWindows(): BrowserWindow[] {\n        const windows = [];\n        if (WindowManager.mainWindow) {\n            windows.push(WindowManager.mainWindow);\n        }\n        if (WindowManager.lrcWindow) {\n            windows.push(WindowManager.lrcWindow);\n        }\n        if (WindowManager.miniModeWindow) {\n            windows.push(WindowManager.miniModeWindow);\n        }\n        return windows;\n    }\n\n    private emit<T extends keyof IWindowEvents>(event: T, data: IWindowEvents[T]) {\n        this.ee.emit(event, data);\n    }\n\n    public on<T extends keyof IWindowEvents>(event: T, listener: (data: IWindowEvents[T]) => void) {\n        this.ee.on(event, listener);\n    }\n\n    /**************************** Main Window ***************************/\n    private createMainWindow() {\n        // 清理旧窗口\n        if (WindowManager.mainWindow) {\n            WindowManager.mainWindow.removeAllListeners();\n            if (!WindowManager.mainWindow.isDestroyed()) {\n                WindowManager.mainWindow.close();\n                WindowManager.mainWindow.destroy();\n            }\n            WindowManager.mainWindow = null;\n        }\n        // 1. 创建主窗口\n        const initSize = AppConfig.getConfig(\"private.mainWindowSize\");\n        const mainWindow = new BrowserWindow({\n            height: initSize?.height ?? 700,\n            width: initSize?.width ?? 1050,\n            minHeight: 700,\n            minWidth: 1050,\n            webPreferences: {\n                preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,\n                nodeIntegration: true,\n                nodeIntegrationInWorker: true,\n                webSecurity: false,\n                sandbox: false,\n                webviewTag: true,\n            },\n            frame: false,\n            icon: nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)),\n        });\n\n        const updateWindowSizeConfig = debounce(() => {\n            const [wWidth, wHeight] = mainWindow.getSize();\n            AppConfig.setConfig({\n                \"private.mainWindowSize\": {\n                    width: wWidth,\n                    height: wHeight,\n                },\n            });\n        }, 300, {\n            leading: false,\n            trailing: true,\n        });\n        mainWindow.on(\"resize\", updateWindowSizeConfig);\n\n        // 2. 加载主界面\n        const initUrl = new URL(MAIN_WINDOW_WEBPACK_ENTRY);\n        initUrl.hash = `/main/musicsheet/${localPluginName}/favorite`;\n        mainWindow.loadURL(initUrl.toString()).then(voidCallback);\n\n        // 3. 开发者工具\n        if (!app.isPackaged) {\n            mainWindow.on(\"ready-to-show\", () => {\n                mainWindow.webContents.openDevTools();\n            });\n        }\n\n        // 4. 主窗口http hack逻辑\n        mainWindow.webContents.session.webRequest.onBeforeSendHeaders(\n            (details, callback) => {\n                /** hack headers */\n                try {\n                    const url = new URL(details.url);\n                    const setHeadersOptions = url.searchParams.get(\"_setHeaders\");\n                    if (!setHeadersOptions) {\n                        throw new Error(\"No Need To Hack\");\n                    }\n                    const originalRequestHeaders = details.requestHeaders ?? {};\n                    let requestHeaders: Record<string, string> = {};\n                    if (setHeadersOptions) {\n                        const decodedHeaders = JSON.parse(\n                            decodeURIComponent(setHeadersOptions),\n                        );\n                        for (const k in originalRequestHeaders) {\n                            requestHeaders[k.toLowerCase()] = originalRequestHeaders[k];\n                        }\n                        for (const k in decodedHeaders) {\n                            requestHeaders[k.toLowerCase()] = decodedHeaders[k];\n                        }\n                    } else {\n                        requestHeaders = details.requestHeaders;\n                    }\n                    callback({\n                        requestHeaders,\n                    });\n                } catch {\n                    callback({\n                        requestHeaders: details.requestHeaders,\n                    });\n                }\n            },\n        );\n\n        mainWindow.on(\"close\", (e) => {\n            if (process.platform === \"win32\" && AppConfig.getConfig(\"normal.closeBehavior\") === \"minimize\") {\n                e.preventDefault();\n                mainWindow.hide();\n                mainWindow.setSkipTaskbar(true);\n            }\n        });\n\n        // 5. 更新thumbbar\n        ThumbBarUtil.setThumbBarButtons(mainWindow, false);\n        WindowManager.mainWindow = mainWindow;\n\n        // 6. 发出信号\n        this.emit(\"WindowCreated\", {\n            windowName: \"main\",\n            browserWindow: mainWindow,\n        });\n    }\n\n    public showMainWindow() {\n        if (!WindowManager.mainWindow || WindowManager.mainWindow.isDestroyed()) {\n            this.createMainWindow();\n        }\n\n        const mainWindow = WindowManager.mainWindow;\n\n        if (mainWindow.isMinimized()) {\n            mainWindow.restore();\n        } else if (mainWindow.isVisible()) {\n            mainWindow.focus();\n        } else {\n            mainWindow.show();\n        }\n        mainWindow.moveTop();\n        mainWindow.setSkipTaskbar(false);\n\n        if (process.platform === \"win32\") {\n            const appState = messageBus.getAppState();\n            ThumbBarUtil.setThumbBarButtons(mainWindow, appState.playerState === PlayerState.Playing);\n        }\n    }\n\n    public closeMainWindow() {\n        WindowManager.mainWindow.close();\n        WindowManager.mainWindow = null;\n    }\n\n    /**************************** Lyric Window ***************************/\n    private static lyricWindowMinSize: ICommon.ISize = {\n        width: 920,\n        height: 92, // 60 + 16 * 2\n    };\n    private static lyricWindowMaxSize: ICommon.ISize = {\n        width: Infinity,\n        height: 240, // 60 + 80 * 2\n    };\n\n    private formatLyricWindowSize(width?: number, height?: number): ICommon.ISize {\n        return {\n            width: Math.min(Math.max(width ?? Infinity, WindowManager.lyricWindowMinSize.width), WindowManager.lyricWindowMaxSize.width),\n            height: Math.min(Math.max(height ?? Infinity, WindowManager.lyricWindowMinSize.height), WindowManager.lyricWindowMaxSize.height),\n        };\n    }\n\n    private evaluateWindowHeight() {\n        const fontSize = AppConfig.getConfig(\"lyric.fontSize\") || 54;\n        return 60 + fontSize * 2;\n    }\n\n    private createLyricWindow() {\n        const initPosition = AppConfig.getConfig(\"private.lyricWindowPosition\");\n        const initSize = AppConfig.getConfig(\"private.lyricWindowSize\");\n\n        let {\n            width,\n            height,\n        } = this.formatLyricWindowSize(initSize?.width ?? WindowManager.lyricWindowMinSize.width, this.evaluateWindowHeight());\n\n        const lyricWindow = new BrowserWindow({\n            height,\n            width,\n            x: initPosition?.x,\n            y: initPosition?.y,\n            transparent: true,\n            webPreferences: {\n                preload: LRC_WINDOW_PRELOAD_WEBPACK_ENTRY,\n                nodeIntegration: true,\n                webSecurity: false,\n                sandbox: false,\n            },\n            minWidth: WindowManager.lyricWindowMinSize.width,\n            minHeight: WindowManager.lyricWindowMinSize.height,\n            maxHeight: WindowManager.lyricWindowMaxSize.height,\n            resizable: true,\n            frame: false,\n            skipTaskbar: true,\n            alwaysOnTop: true,\n            icon: nativeImage.createFromPath(getResourcePath(ResourceName.LOGO_IMAGE)),\n        });\n\n        const display = screen.getDisplayNearestPoint(lyricWindow.getBounds());\n        WindowManager.lyricWindowMaxSize.width = display.bounds.width;\n        lyricWindow.setMaximumSize(WindowManager.lyricWindowMaxSize.width, WindowManager.lyricWindowMaxSize.height);\n\n        // and load the index.html of the app.\n        lyricWindow.loadURL(LRC_WINDOW_WEBPACK_ENTRY);\n\n        if (!app.isPackaged) {\n            // Open the DevTools.\n            lyricWindow.webContents.openDevTools();\n        }\n\n        // 设置窗口可拖拽\n        WindowDrag.setWindowDraggable(lyricWindow, {\n            width, // 实际不生效\n            height, // 实际不生效\n            getWindowSize() {\n                return {\n                    width,\n                    height,\n                };\n            },\n            onDragEnd(point) {\n                AppConfig.setConfig({\n                    \"private.lyricWindowPosition\": point,\n                });\n                const currentDisplayBounds =\n                    screen.getDisplayNearestPoint(point).bounds;\n                if (currentDisplayBounds.width !== WindowManager.lyricWindowMaxSize.width) {\n                    WindowManager.lyricWindowMaxSize.width = currentDisplayBounds.width;\n                    lyricWindow.setMaximumSize(WindowManager.lyricWindowMaxSize.width, WindowManager.lyricWindowMaxSize.height);\n                }\n            },\n        });\n\n        const updateCallback = (patch: IAppConfig, _: any, from: \"main\" | \"renderer\") => {\n            if (from === \"renderer\") {\n                if (patch[\"lyric.fontSize\"]) {\n                    height = this.evaluateWindowHeight();\n                    lyricWindow.setSize(width, height);\n                }\n            }\n        };\n        AppConfig.onConfigUpdated(updateCallback);\n        lyricWindow.on(\"closed\", () => {\n            AppConfig.offConfigUpdated(updateCallback);\n        });\n\n\n        lyricWindow.on(\"resize\", () => {\n            const [wWidth, wHeight] = lyricWindow.getSize();\n            const fontSize = Math.max(Math.min(Math.floor((height - 60) / 2), 80), 16);\n            AppConfig.setConfig({\n                \"lyric.fontSize\": fontSize,\n                \"private.lyricWindowSize\": {\n                    width,\n                    height,\n                },\n            });\n            width = wWidth;\n            height = wHeight;\n\n        });\n\n        // 初始化设置\n        lyricWindow.once(\"ready-to-show\", async () => {\n            const position = AppConfig.getConfig(\"private.lyricWindowPosition\");\n            if (position) {\n                this.normalizeWindowPosition(lyricWindow, position, async (position) => {\n                    AppConfig.setConfig({\n                        \"private.lyricWindowPosition\": position,\n                    });\n                });\n            }\n\n            const lockState = AppConfig.getConfig(\"lyric.lockLyric\");\n\n            if (lockState) {\n                lyricWindow.setIgnoreMouseEvents(true, {\n                    forward: true,\n                });\n            }\n        });\n\n        if (process.platform === \"darwin\") {\n            // @ts-ignore ignore error in windows legacy\n            lyricWindow.invalidateShadow();\n        }\n\n        WindowManager.lrcWindow = lyricWindow;\n        this.emit(\"WindowCreated\", {\n            windowName: \"lyric\",\n            browserWindow: lyricWindow,\n        });\n    }\n\n\n    public showLyricWindow() {\n        if (!WindowManager.lrcWindow) {\n            this.createLyricWindow();\n        }\n\n        const lrcWindow = WindowManager.lrcWindow;\n\n        lrcWindow.show();\n        AppConfig.setConfig({\n            \"lyric.enableDesktopLyric\": true,\n        });\n\n    }\n\n    public closeLyricWindow() {\n        WindowManager.lrcWindow?.close();\n        WindowManager.lrcWindow = null;\n        AppConfig.setConfig({\n            \"lyric.enableDesktopLyric\": false,\n        });\n    }\n\n    /**************************** MiniMode Window ***************************/\n    private createMiniModeWindow() {\n        // Create the browser window.\n        const width = 340;\n        const height = 72;\n        const initPosition = AppConfig.getConfig(\"private.minimodeWindowPosition\");\n\n        const miniWindow = new BrowserWindow({\n            height,\n            width,\n            x: initPosition?.x,\n            y: initPosition?.y,\n            webPreferences: {\n                preload: MINIMODE_WINDOW_PRELOAD_WEBPACK_ENTRY,\n                nodeIntegration: true,\n                nodeIntegrationInWorker: true,\n                webSecurity: false,\n                sandbox: false,\n            },\n            resizable: false,\n            frame: false,\n            skipTaskbar: true,\n            alwaysOnTop: true,\n        });\n\n        // and load the index.html of the app.\n        const initUrl = new URL(MINIMODE_WINDOW_WEBPACK_ENTRY);\n        miniWindow.loadURL(initUrl.toString());\n\n        if (!app.isPackaged) {\n            miniWindow.on(\"ready-to-show\", () => {\n                // Open the DevTools.\n                miniWindow.webContents.openDevTools();\n            });\n        }\n\n        WindowDrag.setWindowDraggable(miniWindow, {\n            width,\n            height,\n            onDragEnd(point) {\n                AppConfig.setConfig({\n                    \"private.minimodeWindowPosition\": point,\n                });\n            },\n        });\n\n        miniWindow.once(\"ready-to-show\", () => {\n            const position = AppConfig.getConfig(\"private.minimodeWindowPosition\");\n            if (position) {\n                this.normalizeWindowPosition(miniWindow, position, async (position) => {\n                    AppConfig.setConfig({\n                        \"private.minimodeWindowPosition\": position,\n                    });\n                });\n            }\n\n        });\n        WindowManager.miniModeWindow = miniWindow;\n        this.emit(\"WindowCreated\", {\n            windowName: \"minimode\",\n            browserWindow: miniWindow,\n        });\n    }\n\n    public showMiniModeWindow() {\n        if (!WindowManager.miniModeWindow) {\n            this.createMiniModeWindow();\n        }\n\n        const miniWindow = WindowManager.miniModeWindow;\n\n        if (miniWindow.isMinimized()) {\n            miniWindow.restore();\n        } else if (miniWindow.isVisible()) {\n            miniWindow.focus();\n        } else {\n            miniWindow.show();\n        }\n        miniWindow.moveTop();\n        miniWindow.setSkipTaskbar(false);\n        AppConfig.setConfig({\n            \"private.minimode\": true,\n        });\n    }\n\n\n    public closeMiniModeWindow() {\n        WindowManager.miniModeWindow?.close();\n        WindowManager.miniModeWindow = null;\n        AppConfig.setConfig({\n            \"private.minimode\": false,\n        });\n    }\n\n    private normalizeWindowPosition(window: BrowserWindow, position: ICommon.IPoint, onNormalized: (position: ICommon.IPoint) => void) {\n        const currentDisplayBounds =\n            screen.getDisplayNearestPoint(position).bounds;\n        const windowBounds = window.getBounds();\n        // 如果完全在是窗外，重置位置\n        const [left, top, right, bottom] = [\n            position.x,\n            position.y,\n            position.x + windowBounds.width,\n            position.y + windowBounds.height,\n        ];\n        let needMakeup = false;\n        if (left > currentDisplayBounds.x + currentDisplayBounds.width) {\n            position.x =\n                currentDisplayBounds.x + currentDisplayBounds.width - windowBounds.width;\n            needMakeup = true;\n        } else if (right < currentDisplayBounds.x) {\n            position.x = currentDisplayBounds.x;\n            needMakeup = true;\n        }\n        if (top > currentDisplayBounds.y + currentDisplayBounds.height) {\n            position.y =\n                currentDisplayBounds.y + currentDisplayBounds.height - windowBounds.height;\n            needMakeup = true;\n        } else if (bottom < currentDisplayBounds.y) {\n            position.y = currentDisplayBounds.y;\n            needMakeup = true;\n        }\n        window.setPosition(position.x, position.y, false);\n        if (needMakeup) {\n            onNormalized(position);\n        }\n    }\n}\n\n\nexport default new WindowManager();\n"
  },
  {
    "path": "src/preload/common-preload.ts",
    "content": "// See the Electron documentation for details on how to use preload scripts:\nimport { contextBridge } from \"electron\";\nimport path from \"path\";\nimport \"electron-log/preload\";\nimport \"@shared/i18n/preload\";\nimport \"@shared/global-context/preload\";\nimport \"@shared/themepack/preload\";\nimport \"@shared/app-config/preload\";\nimport \"@shared/utils/preload\";\nimport \"@shared/window-drag/preload\";\n\n// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts\n\ncontextBridge.exposeInMainWorld(\"path\", path);\n"
  },
  {
    "path": "src/preload/extension.ts",
    "content": "import \"./common-preload\";\n\nimport \"@/shared/message-bus/preload/extension\";\n"
  },
  {
    "path": "src/preload/index.ts",
    "content": "// See the Electron documentation for details on how to use preload scripts:\nimport \"./common-preload\";\n// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts\n\nimport \"@shared/service-manager/preload\";\nimport \"@shared/plugin-manager/preload\";\nimport \"@shared/message-bus/preload/main\";\nimport \"@shared/short-cut/preload\";\n"
  },
  {
    "path": "src/renderer/app.scss",
    "content": ".app-container {\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  position: relative;\n\n  & .body-container {\n    width: 100%;\n    height: 0;\n    flex: 1;\n    display: flex;\n    position: relative;\n  }\n}\n\n.tab-list-container {\n  display: flex;\n  margin-top: 1.2rem;\n  flex-shrink: 0;\n  column-gap: 2.5rem;\n  overflow-x: auto;\n  padding-bottom: 6px;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  /* 当鼠标悬浮时显示滚动条 */\n  &:hover {\n    &::-webkit-scrollbar {\n      display: block;\n    }\n  }\n\n  & .tab-list-item {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    position: relative;\n    font-size: 1.1rem;\n    padding: 0 0 0.5rem;\n    outline: none;\n    cursor: pointer;\n    opacity: 0.7;\n    white-space: nowrap;\n    user-select: none;\n\n    &:hover {\n      opacity: 0.9;\n    }\n\n    &[data-headlessui-state=\"selected\"] {\n      font-weight: 600;\n      opacity: 1;\n\n      &::after {\n        content: \"\";\n        position: absolute;\n        width: 70%;\n        min-width: 2rem;\n        height: 4px;\n        border-radius: 2px;\n        left: 50%;\n        bottom: 0;\n        background-color: var(--primaryColor);\n        transform: translateX(-50%);\n      }\n    }\n  }\n}\n\n.tab-panels-container {\n  flex: 1;\n\n  & .tab-panel-container {\n    height: 100%;\n    outline: none;\n  }\n}\n\n// 动态主题\n:root {\n  --primaryColor: #f17d34; // 主色调\n  --backgroundColor: #fdfdfd; // 背景色\n  --dividerColor: rgba(0, 0, 0, 0.1); // 分割线颜色\n  --listEvenColor: rgba(0, 0, 0, 0.05); // 列表背景色（奇数条目）\n  --listHoverColor: rgba(0, 0, 0, 0.05); // 列表悬浮颜色\n  --listActiveColor: rgba(0, 0, 0, 0.1); // 列表选中颜色\n  --textColor: #333333; // 主文本颜色\n  --maskColor: rgba(51, 51, 51, 0.5); // 遮罩层颜色\n  /* --backdropColor: #fdfdfd; // 弹窗等地方的背景颜色，默认和背景色一致*/\n  --shadowColor: rgba(0, 0, 0, 0.2);\n  /** --shadow:  // 全部的shadow属性 */\n  --placeholderColor: #f4f4f4;\n\n  --successColor: #08a34c;\n  --dangerColor: #fc5f5f;\n  --infoColor: #0a95c8;\n  --linkColor: #0c66fc;\n  /* --headerPlaceholderColor: rgba($color: #000, $alpha: 0.14); */\n  --headerTextColor: white;\n  /* --musicBarTextColor: #000; // 有必要再加*/\n  --animate-duration: 300ms !important;\n  --scrollbarWidth: 12px;\n\n  --appHeaderHeight: 54px;\n  --appMusicBarHeight: 64px;\n\n  // 基础字体\n  font-size: 13px;\n  color: var(--textColor);\n\n}\n\na {\n  color: var(--linkColor);\n}"
  },
  {
    "path": "src/renderer/app.tsx",
    "content": "import AppHeader from \"./components/Header\";\n\nimport \"./app.scss\";\nimport MusicBar from \"./components/MusicBar\";\nimport { Outlet } from \"react-router\";\nimport PanelComponent from \"./components/Panel\";\nimport MusicDetail from \"@renderer/components/MusicDetail\";\n\nexport default function App() {\n    return (\n        <div className=\"app-container\">\n            <AppHeader></AppHeader>\n            <div className=\"body-container\">\n                <Outlet></Outlet>\n                <PanelComponent></PanelComponent>\n            </div>\n            <MusicDetail></MusicDetail>\n            <MusicBar></MusicBar>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/A/index.tsx",
    "content": "import { shellUtil } from \"@shared/utils/renderer\";\n\nexport default function A(\n    props: React.DetailedHTMLProps<\n        React.AnchorHTMLAttributes<HTMLAnchorElement>,\n        HTMLAnchorElement\n    >,\n) {\n    return (\n        <a\n            {...props}\n            href={\"javascript:void(0);\"}\n            onClick={(...args) => {\n                if (props.href) {\n                    shellUtil.openExternal(props.href);\n                }\n                props?.onClick?.(...args);\n            }}\n        ></a>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/AnimatedDiv/index.tsx",
    "content": "import React, { useMemo, useState, useEffect } from \"react\";\n\n\n\ninterface IProps\n    extends React.DetailedHTMLProps<\n        React.HTMLAttributes<HTMLDivElement>,\n        HTMLDivElement\n    > {\n    // 展示条件\n    showIf?: boolean;\n    // 挂载动画\n    mountClassName?: string;\n    // 卸载动画\n    unmountClassName?: string;\n    onMountAnimationEnd?: () => void;\n    onUnmountAnimationEnd?: () => void;\n}\n\n/**\n * 动画div组件\n * @returns\n */\nexport default function AnimatedDiv(props: IProps) {\n    const {\n        showIf = true,\n        mountClassName,\n        unmountClassName,\n        onMountAnimationEnd,\n        onUnmountAnimationEnd,\n        className,\n        onAnimationEnd,\n    } = props ?? {};\n\n    const [shouldMount, setShouldMount] = useState(false);\n    const [_animationPlaying, setAnimationPlaying] = useState(false);\n\n    const filteredProps: Record<string, any> = useMemo(() => {\n        const res = {\n            ...(props ?? {}),\n        } as any;\n        delete res.showIf;\n        delete res.mountClassName;\n        delete res.unmountClassName;\n        return res;\n    }, [props]);\n\n    useEffect(() => {\n        if (showIf) {\n            setShouldMount(true);\n        } else if (!unmountClassName) {\n            setShouldMount(false);\n        }\n    }, [showIf]);\n\n    return shouldMount ? (\n        <div\n            {...(filteredProps)}\n            className={`${className ?? \"\"} ${showIf ? mountClassName ?? \"\" : unmountClassName ?? \"\"\n            }`}\n            onAnimationEnd={(...args) => {\n                onAnimationEnd?.(...args);\n                if (!showIf) {\n                    // 如果showIf是false，表示当前播放的是卸载状态的动画\n                    setShouldMount(false);\n                    onUnmountAnimationEnd?.();\n                } else {\n                    setShouldMount(true);\n                    onMountAnimationEnd?.();\n                }\n                setAnimationPlaying(prev => !prev);\n            }}\n        ></div>\n    ) : null;\n}\n"
  },
  {
    "path": "src/renderer/components/ArtistItem/index.scss",
    "content": ".components--artist-item-container {\n  $width: 140px;\n  $height: 216px;\n  width: $width;\n  height: $height;\n\n  & .artist-img-wrapper {\n    width: $width;\n    height: $width;\n    -webkit-user-drag: none;\n    border-radius: 8px;\n    overflow: hidden;\n    position: relative;\n\n    & .artist-play-info {\n      position: absolute;\n      box-sizing: border-box;\n      left: 0;\n      bottom: 0;\n      background-color: rgba($color: #000000, $alpha: 0.2);\n      backdrop-filter: blur(50px);\n      width: 100%;\n      height: 1.8rem;\n      font-size: 0.85rem;\n      display: flex;\n      align-items: center;\n      justify-content: space-between;\n      color: #eee;\n      padding-left: 4px;\n      padding-right: 4px;\n      white-space: nowrap;\n\n      & .play-count {\n        display: flex;\n        align-items: center;\n\n        & svg {\n          margin-right: 2px;\n        }\n      }\n    }\n\n    & img {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: $width;\n      height: $width;\n      -webkit-user-drag: none;\n      border-radius: 8px;\n      object-fit: cover;\n      transition: transform ease-out 400ms;\n\n      &:hover {\n        transform: scale(1.1);\n      }\n    }\n  }\n\n  & .media-info {\n    margin-top: 6px;\n    height: $height - $width - 6px;\n    width: 100%;\n\n    & .title {\n      font-size: 1.1rem;\n      overflow: hidden;\n      white-space: nowrap;\n      text-overflow: ellipsis;\n\n      &:hover {\n        color: var(--primaryColor);\n      }\n    }\n\n    & .desc {\n      font-size: 0.9rem;\n      margin-top: 4px;\n      display: -webkit-box;\n      -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical;\n      opacity: 0.8;\n      overflow: hidden;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/ArtistItem/index.tsx",
    "content": "import { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport \"./index.scss\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport { memo } from \"react\";\n\ninterface IArtistItemProps {\n    artistItem: IArtist.IArtistItem;\n    onClick?: (artistItem: IArtist.IArtistItem) => void;\n}\n\nfunction ArtistItem(props: IArtistItemProps) {\n    const { artistItem, onClick } = props;\n\n    return (\n        <div\n            className=\"components--artist-item-container\"\n            role=\"button\"\n            onClick={() => {\n                onClick?.(artistItem);\n            }}\n        >\n            <div className=\"artist-img-wrapper\">\n                <img\n                    src={artistItem?.avatar || albumImg}\n                    onError={setFallbackAlbum}\n                ></img>\n                {/* <Condition\n          condition={\n            mediaItem?.playCount || mediaItem?.worksNum || mediaItem?.createAt\n          }\n        >\n          <div className=\"album-play-info\">\n            {mediaItem?.createAt ? (\n              dayjs(mediaItem.createAt).format(\"YYYY-MM-DD\")\n            ) : (\n              <div></div>\n            )}\n            <div className=\"play-count\">\n              <Condition condition={mediaItem?.playCount}>\n                <SvgAsset iconName={\"headphone\"} size={14}></SvgAsset>\n                {normalizeNumber(mediaItem?.playCount)}\n              </Condition>\n            </div>\n          </div>\n        </Condition> */}\n            </div>\n            <div className=\"media-info\">\n                <div className=\"title\" title={artistItem?.name}>\n                    {artistItem?.name}\n                </div>\n                <div className=\"desc\" title={artistItem?.description?.replace?.(\"\\\\n\", \"\\n\")}>\n                    {(artistItem?.description ?? \"\").split(\"\\\\n\").map((item, index) => (\n                        <div key={index}>{item}</div>\n                    ))}\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default memo(\n    ArtistItem,\n    (prev, curr) =>\n        prev.artistItem === curr.artistItem && prev.onClick === curr.onClick,\n);\n"
  },
  {
    "path": "src/renderer/components/BottomLoadingState/index.scss",
    "content": ".bottom-loading-state {\n  width: 100%;\n  height: 4rem;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  user-select: none;\n}\n\n.bottom-loading-state--reach-end {\n  opacity: 0.8;\n}\n\n.bottom-loading-state--loadmore {\n  &:hover {\n    color: var(--primaryColor);\n  }\n}\n\n.bottom-loading-state--loading {\n  display: flex;\n  align-items: center;\n}\n\n$loading-size: 20px;\n\n.lds-dual-ring {\n  display: inline-block;\n  width: $loading-size;\n  height: $loading-size;\n  margin-right: 0.5rem;\n}\n.lds-dual-ring:after {\n  content: \" \";\n  display: block;\n  width: $loading-size * 0.8;\n  height: $loading-size * 0.8;\n  margin: $loading-size * 0.1;\n  border-radius: 50%;\n  border: $loading-size * 0.1 solid var(--textColor);\n  border-color: var(--textColor) transparent var(--textColor) transparent;\n  animation: lds-dual-ring 1.2s linear infinite;\n}\n@keyframes lds-dual-ring {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/BottomLoadingState/index.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport \"./index.scss\";\nimport { useTranslation } from \"react-i18next\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface IProps {\n    state: RequestStateCode;\n    onLoadMore?: () => void;\n}\n\nexport default function BottomLoadingState(props: IProps) {\n    const { state, onLoadMore } = props;\n    const stateRef = useRef<RequestStateCode>(state);\n    stateRef.current = state;\n\n    const containerRef = useRef<HTMLDivElement | null>(null);\n\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        const intersectionObserver = new IntersectionObserver((entries) => {\n            if(AppConfig.getConfig(\"normal.autoLoadMore\") && stateRef.current === RequestStateCode.PARTLY_DONE && entries[0].intersectionRatio > 0) {\n                if (onLoadMore) {\n                    onLoadMore();\n                }\n            }\n        });\n\n        if (containerRef.current) {\n            intersectionObserver.observe(containerRef.current);\n        }\n\n        return () => {\n            if (containerRef.current) {\n                intersectionObserver.unobserve(containerRef.current);\n            }\n        };\n    }, [onLoadMore]);\n\n\n    let component = null;\n\n    if (state === RequestStateCode.FINISHED) {\n        component = <span className=\"bottom-loading-state--reach-end\">{t(\"bottom_loading_state.reached_end\")}</span>;\n    } else if (state === RequestStateCode.PENDING_REST_PAGE) {\n        component = <>\n            <div className=\"lds-dual-ring\"></div> {t(\"bottom_loading_state.loading\")}\n        </>;\n    } else if (state === RequestStateCode.PARTLY_DONE) {\n        component = <span className=\"bottom-loading-state--loadmore\" role=\"button\" onClick={onLoadMore}>\n            {t(\"bottom_loading_state.load_more\")}\n        </span>;\n    }\n\n    return <div className=\"bottom-loading-state\" ref={containerRef}>\n        {component}\n    </div>;\n\n  \n}\n"
  },
  {
    "path": "src/renderer/components/Checkbox/index.scss",
    "content": ".checkbox-container {\n  width: 1rem;\n  height: 1rem;\n  border-radius: 2px;\n  border: 1px solid currentColor;\n  position: relative;\n\n  & svg {\n    position: absolute;\n    width: 1rem;\n    height: 1rem;\n    left: 0;\n    top: 0;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Checkbox/index.tsx",
    "content": "import { CSSProperties } from \"react\";\nimport SvgAsset from \"../SvgAsset\";\nimport \"./index.scss\";\n\ninterface ICheckboxProps {\n    checked?: boolean;\n    onChange?: (newChecked: boolean) => void;\n    style?: CSSProperties\n}\n\nexport default function Checkbox(props: ICheckboxProps) {\n    const { checked, onChange, style } = props;\n\n    return (\n        <div\n            className=\"checkbox-container\"\n            style={style}\n            role={onChange ? \"button\" : undefined}\n            onClick={() => {\n                onChange?.(!checked);\n            }}\n        >\n            {checked ? <SvgAsset iconName=\"check\"></SvgAsset> : null}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Condition/index.tsx",
    "content": "import { ReactNode } from \"react\";\n\ninterface IConditionProps {\n    condition: any;\n    truthy?: ReactNode;\n    falsy?: ReactNode;\n    children?: ReactNode;\n}\n\nexport default function Condition(props: IConditionProps) {\n    const { condition, truthy, falsy, children } = props;\n    return <>{condition ? truthy ?? children : falsy}</>;\n}\n\ninterface IIfProps {\n    condition: any;\n    children?: any;\n}\n\ninterface ICondProps {\n    children?: ReactNode | ReactNode[];\n}\nfunction Truthy(props: ICondProps) {\n    return <>{props?.children}</>;\n}\n\nfunction Falsy(props: ICondProps) {\n    return <>{props?.children}</>;\n}\n\nfunction If(props: IIfProps) {\n    const { condition, children } = props;\n\n    if (!children) {\n        return null;\n    }\n\n    let _children: any;\n    if (Array.isArray) {\n        _children = children.map((it: any) =>\n            condition\n                ? it.type !== Falsy\n                    ? it\n                    : null\n                : it.type !== Truthy\n                    ? it\n                    : null,\n        );\n    } else {\n        _children = condition\n            ? _children!.type !== Falsy\n                ? _children\n                : null\n            : _children.type !== Truthy\n                ? _children\n                : null;\n    }\n\n    return _children;\n}\n\nIf.Truthy = Truthy;\nIf.Falsy = Falsy;\n\n\nfunction IfTruthy(props: IIfProps) {\n    const { condition, children } = props;\n\n    return condition ? children : null;\n}\n\nfunction IfFalsy(props: IIfProps) {\n    const { condition, children } = props;\n\n    return condition ? null : children;\n}\n\nexport { If, IfTruthy, IfFalsy };\n"
  },
  {
    "path": "src/renderer/components/ContextMenu/index.scss",
    "content": ".context-menu--single-column-container {\n  position: fixed;\n  overflow-y: auto;\n  z-index: 999999;\n  border-radius: 6px;\n\n  & .divider {\n    margin: 0;\n    height: 1px;\n  }\n\n  & .menu-item {\n    box-sizing: border-box;\n    width: 100%;\n    display: flex;\n    align-items: center;\n    padding-left: 8px;\n    padding-right: 8px;\n    position: relative;\n\n    & span {\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n\n    & .menu-item-icon {\n      $item-size: 1.2rem;\n      width: $item-size;\n      height: $item-size;\n      margin-right: 8px;\n\n      & svg {\n        width: $item-size;\n        height: $item-size;\n      }\n    }\n\n    & .menu-item-expand {\n      $tag-size: 4px;\n      border: $tag-size solid transparent;\n      border-left-color: currentColor;\n      position: absolute;\n      right: 12px;\n    }\n\n    &:hover {\n      background: var(--listHoverColor);\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/ContextMenu/index.tsx",
    "content": "import Store from \"@/common/store\";\nimport SvgAsset, { SvgAssetIconNames } from \"../SvgAsset\";\nimport \"./index.scss\";\nimport Condition, { If, IfTruthy } from \"../Condition\";\nimport { ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\n\nexport interface IContextMenuItem {\n    /** 左侧图标 */\n    icon?: SvgAssetIconNames;\n    /** 列表标题 */\n    title?: string;\n    /** 是否是分割线 */\n    divider?: boolean;\n    /** 是否展示 */\n    show?: boolean;\n    /** 点击事件 */\n    onClick?: (value?: IContextMenuItem) => void;\n    /** 子菜单 */\n    subMenu?: IContextMenuItem[];\n}\n\ninterface IContextMenuData {\n    /** 菜单 */\n    menuItems?: IContextMenuItem[];\n    /** 出现位置 x */\n    x: number;\n    /** 出现位置 y */\n    y: number;\n    /** 设置子目录 */\n    setSubMenu?: (\n        subMenu?: Omit<IContextMenuData, \"setSubMenu\">,\n        menuItem?: IContextMenuItem\n    ) => void;\n    onItemClick?: (value: any) => void;\n\n    /** 自定义的菜单 */\n    width?: number;\n    height?: number;\n    component?: ReactNode;\n}\n\nconst contextMenuDataStore = new Store<IContextMenuData | null>(null);\n\nexport function showContextMenu(\n    contextMenuData: Pick<IContextMenuData, \"menuItems\" | \"x\" | \"y\">,\n) {\n    contextMenuDataStore.setValue(contextMenuData);\n}\n\nexport function showCustomContextMenu(\n    contextMenuData: Pick<\n        IContextMenuData,\n    \"x\" | \"y\" | \"width\" | \"height\" | \"component\"\n    >,\n) {\n    contextMenuDataStore.setValue(contextMenuData);\n}\n\nfunction hideContextMenu() {\n    contextMenuDataStore.setValue(null);\n}\n\nconst menuItemWidth = 240;\nconst menuItemHeight = 32;\nconst menuContainerMaxHeight = menuItemHeight * 10;\n\nfunction SingleColumnContextMenuComponent(props: IContextMenuData) {\n    const { menuItems, x, y, setSubMenu, onItemClick } = props;\n    const menuContainerRef = useRef<HTMLDivElement>();\n\n    return (\n        <div\n            className=\"context-menu--single-column-container shadow backdrop-color\"\n            style={{\n                width: menuItemWidth,\n                paddingTop: menuItemHeight / 4,\n                paddingBottom: menuItemHeight / 4,\n                top: y,\n                left: x,\n                maxHeight: menuContainerMaxHeight,\n            }}\n            ref={menuContainerRef}\n        >\n            {menuItems.map((item, index) => (\n                <IfTruthy condition={item.show !== false} key={index}>\n                    <If condition={!item.divider}>\n                        <If.Falsy>\n                            <div className=\"divider\"></div>\n                        </If.Falsy>\n                        <If.Truthy>\n                            <div\n                                className=\"menu-item\"\n                                role=\"button\"\n                                onClick={() => {\n                                    item.onClick?.();\n                                    onItemClick?.(item);\n                                }}\n                                onMouseEnter={(e) => {\n                                    const subMenu = item.subMenu;\n                                    if (!subMenu) {\n                                        setSubMenu?.(null, item);\n                                        return;\n                                    }\n\n                                    const realPos =\n                    y +\n                    (e.target as HTMLDivElement).offsetTop -\n                    menuContainerRef.current.scrollTop;\n                                    const realHeight = Math.min(\n                                        subMenu.length * menuItemHeight,\n                                        menuContainerMaxHeight,\n                                    );\n                                    let [subX, subY] = [\n                                        x - menuItemWidth - offset,\n                                        realPos - realHeight / 2,\n                                    ];\n                                    if (x < window.innerWidth - x - offset - menuItemWidth) {\n                                        subX = x + menuItemWidth + offset;\n                                    }\n                                    if (subY < 54) {\n                                        subY = 54;\n                                    }\n                                    if (subY + realHeight > window.innerHeight - 64 - offset) {\n                                        subY = window.innerHeight - 64 - realHeight - offset;\n                                    }\n                                    setSubMenu?.(\n                                        {\n                                            menuItems: subMenu,\n                                            x: subX,\n                                            y: subY,\n                                        },\n                                        item,\n                                    );\n                                }}\n                                style={{\n                                    height: menuItemHeight,\n                                }}\n                            >\n                                <IfTruthy condition={item.icon}>\n                                    <div className=\"menu-item-icon\">\n                                        <SvgAsset iconName={item.icon}></SvgAsset>\n                                    </div>\n                                </IfTruthy>\n                                <span>{item.title}</span>\n                                <IfTruthy condition={item.subMenu}>\n                                    <div className=\"menu-item-expand\"></div>\n                                </IfTruthy>\n                            </div>\n                        </If.Truthy>\n                    </If>\n                </IfTruthy>\n            ))}\n        </div>\n    );\n}\n\nconst offset = 6;\n\nexport function ContextMenuComponent() {\n    const contextMenuData = contextMenuDataStore.useValue();\n    const { menuItems, x, y, width, height, component } = contextMenuData ?? {};\n    const [subMenuData, setSubMenuData] = useState<IContextMenuData | null>(null);\n\n    const [actualX, actualY] = useMemo(() => {\n        if (x === undefined || y === undefined) {\n            return [-1000, -1000];\n        }\n        const isLeft = x < window.innerWidth / 2 ? 0 : 1;\n        const isTop = y < window.innerHeight / 2 ? 0 : 2;\n    \n        const containerHeight = Math.min(\n            component\n                ? height\n                : menuItems.reduce(\n                    (prev, curr) =>\n                        prev +\n              (curr.show !== false ? (curr.divider ? 1 : menuItemHeight) : 0),\n                    menuItemHeight / 2,\n                ),\n            menuContainerMaxHeight,\n        );\n\n        const containerWidth = width ?? menuItemWidth;\n\n        switch (isLeft + isTop) {\n            case 0: // 左上角\n                return [x + offset, y + offset];\n            case 1: // 右上角\n                return [x - containerWidth - offset, y + offset];\n            case 2: // 左下角\n                return [x + offset, y - offset - containerHeight];\n            case 3: // 右下角\n                return [x - containerWidth - offset, y - offset - containerHeight];\n        }\n    }, [x, y]);\n\n    useEffect(() => {\n        const contextClickListener = () => {\n            if (contextMenuDataStore.getValue()) {\n                hideContextMenu();\n            }\n        };\n\n        window.addEventListener(\"click\", contextClickListener);\n        return () => {\n            window.removeEventListener(\"click\", contextClickListener);\n        };\n    }, []);\n\n    useEffect(() => {\n        setSubMenuData(null);\n    }, [contextMenuData]);\n\n\n    return (\n        <If condition={contextMenuData !== null && !component}>\n            <If.Truthy>\n                <SingleColumnContextMenuComponent\n                    menuItems={menuItems}\n                    x={actualX}\n                    y={actualY}\n                    setSubMenu={(data, menuItem) => {\n                        setSubMenuData(\n                            data\n                                ? {\n                                    ...data,\n                                    onItemClick(value) {\n                                        menuItem?.onClick?.(value);\n                                    },\n                                }\n                                : data,\n                        );\n                    }}\n                ></SingleColumnContextMenuComponent>\n                <Condition condition={subMenuData}>\n                    <SingleColumnContextMenuComponent\n                        menuItems={subMenuData?.menuItems}\n                        x={subMenuData?.x}\n                        y={subMenuData?.y}\n                        onItemClick={subMenuData?.onItemClick}\n                    ></SingleColumnContextMenuComponent>\n                </Condition>\n            </If.Truthy>\n            <If.Falsy>\n                <div\n                    className=\"context-menu--single-column-container shadow backdrop-color\"\n                    style={{\n                        width: width ?? menuItemWidth,\n                        top: actualY,\n                        left: actualX,\n                        maxHeight: menuContainerMaxHeight,\n                    }}\n                >\n                    {component}\n                </div>\n            </If.Falsy>\n        </If>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/DragReceiver/index.scss",
    "content": "\n.components--drag-receiver {\n    position: absolute;\n    left: 0;\n    height: 12px;\n    width: 100%;\n    display: flex;\n    align-items: center;\n  \n    & .components--drag-receiver-content {\n      width: 100%;\n      height: 2px;\n      background-color: var(--primaryColor);\n      pointer-events: none;\n    }\n  }\n  \n  .components--drag-receiver-top {\n    top: -6px;\n  }\n  \n  .components--drag-receiver-bottom {\n    bottom: -6px;\n  }\n  \n  .components--drag-receiver-table-container {\n    width: 0;\n    max-width: 0;\n    flex-basis: 0;\n    flex-grow: 0;\n  }"
  },
  {
    "path": "src/renderer/components/DragReceiver/index.tsx",
    "content": "import { useCallback, useState, DragEvent } from \"react\";\nimport { IfTruthy } from \"../Condition\";\nimport \"./index.scss\";\n\ninterface IDragReceiverProps {\n    // 位置：顶部/底部\n    position: \"top\" | \"bottom\";\n    // 当前响应器的下标\n    rowIndex: number;\n    // 释放事件\n    onDrop?: (from: number, to: number) => void;\n    /** 用来匹配拖拽源的tag */\n    tag?: string;\n    /** 是否需要td标签包裹 */\n    insideTable?: boolean;\n}\n\nexport default function DragReceiver(props: IDragReceiverProps) {\n    const { position, rowIndex, onDrop, tag, insideTable } = props;\n    const [draggingOver, setDraggingOver] = useState(false);\n\n    const onDragOver = useCallback(() => {\n        setDraggingOver(true);\n    }, []);\n\n    const onDragLeave = useCallback(() => {\n        setDraggingOver(false);\n    }, []);\n\n    const contentComponent = (\n        <div\n            className={`components--drag-receiver components--drag-receiver-${[\n                position,\n            ]}`}\n            onDragOver={onDragOver}\n            onDragLeave={onDragLeave}\n            onDrop={(e) => {\n                const itemIndex = +e.dataTransfer.getData(\"itemIndex\");\n                const itemTag = e.dataTransfer.getData(\"itemTag\");\n                setDraggingOver(false);\n\n                const _itemTag = (itemTag === \"null\" || itemTag === \"undefined\") ? null : `${itemTag}`;\n                const _tag = tag ? `${tag}` : null;\n                if (_itemTag !== _tag) {\n                    // tag 不一致 忽略\n                    return;\n                }\n                if (itemIndex >= 0) {\n                    onDrop?.(itemIndex, rowIndex);\n                }\n            }}\n        >\n            <IfTruthy condition={draggingOver}>\n                <div className=\"components--drag-receiver-content\"></div>\n            </IfTruthy>\n        </div>\n    );\n\n    return insideTable ? (\n        <td className=\"components--drag-receiver-table-container\">{contentComponent}</td>\n    ) : (\n        contentComponent\n    );\n}\n\nexport function startDrag(\n    e: DragEvent,\n    itemIndex: number | string,\n    tag?: string,\n) {\n    e.dataTransfer.setData(\"itemIndex\", `${itemIndex}`);\n    e.dataTransfer.setData(\"itemTag\", tag ?? null);\n}\n"
  },
  {
    "path": "src/renderer/components/Empty/index.scss",
    "content": ".components--empty-container {\n    width: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    min-height: 300px;\n}"
  },
  {
    "path": "src/renderer/components/Empty/index.tsx",
    "content": "import { CSSProperties } from \"react\";\nimport \"./index.scss\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IEmptyProps {\n    style?: CSSProperties;\n}\n\nexport default function Empty(props: IEmptyProps) {\n    const { style } = props;\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"components--empty-container\" style={style}>\n            {t(\"empty.hint_empty\")}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Header/index.scss",
    "content": ".header-container {\n  width: 100vw;\n  height: var(--appHeaderHeight, 54px);\n  background-color: var(--primaryColor);\n  display: flex;\n  box-sizing: border-box;\n  justify-content: space-between;\n  align-items: center;\n  padding-left: 16px;\n  -webkit-app-region: drag;\n  flex-shrink: 0;\n  font-size: 1rem;\n  position: relative;\n  z-index: 2000;\n\n  & .left-part {\n    display: flex;\n    align-items: center;\n\n    & .logo {\n      width: 142px;\n      height: 1.2rem;\n      color: var(--headerTextColor);\n      display: flex;\n      align-items: center;\n\n      & svg {\n        height: 1.1rem;\n        width: auto;\n      }\n    }\n\n    & .header-search {\n      -webkit-app-region: none;\n      background-color: var(\n        --headerPlaceholderColor,\n        rgba($color: #000, $alpha: 0.14)\n      );\n      position: relative;\n      min-width: 220px;\n      border-radius: 8px;\n      padding: 0.5rem 8px;\n      display: flex;\n      align-items: center;\n      margin-left: 14px;\n\n      & .header-search-input {\n        flex: auto;\n        font-size: 1rem;\n        line-height: 1.2rem;\n        outline: none;\n        border: none;\n        background-color: transparent !important;\n        color: var(--headerTextColor);\n        padding: 0;\n\n        &::placeholder {\n          opacity: 0.7;\n          user-select: none;\n          color: var(--headerTextColor);\n        }\n      }\n\n      & .search-submit {\n        $box-size: 1.3em;\n        margin-left: 2px;\n        width: #{$box-size};\n        height: #{$box-size};\n        color: var(--headerTextColor);\n        opacity: 0.7;\n        cursor: pointer;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        &:hover {\n          opacity: 1;\n        }\n\n        & svg {\n          width: #{$box-size};\n          height: #{$box-size};\n        }\n      }\n    }\n  }\n\n  & .right-part {\n    height: 100%;\n    color: var(--headerTextColor);\n    display: flex;\n    justify-content: flex-end;\n    align-items: center;\n    -webkit-app-region: none;\n    padding-right: 16px;\n    gap: 4px;\n\n    & .header-divider {\n      height: 16px;\n      width: 1px;\n      opacity: 0.2;\n      background-color: var(--headerTextColor);\n    }\n\n    & .sparkles-icon:hover {\n      animation: vibrate 0.3s linear infinite both;\n    }\n\n    & .header-button {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      width: 26px;\n      height: 20px;\n      opacity: 0.6;\n      cursor: pointer;\n\n      &:hover {\n        opacity: 1;\n      }\n\n      & svg {\n        width: 20px;\n        height: 20px;\n      }\n    }\n  }\n}\n\n@keyframes vibrate {\n  0% {\n    transform: translate(0);\n  }\n  20% {\n    transform: translate(-1px, 1px);\n  }\n  40% {\n    transform: translate(-1px, -1px);\n  }\n  60% {\n    transform: translate(1px, 1px);\n  }\n  80% {\n    transform: translate(1px, -1px);\n  }\n  100% {\n    transform: translate(0);\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Header/index.tsx",
    "content": "import SvgAsset from \"../SvgAsset\";\nimport \"./index.scss\";\nimport { showModal } from \"../Modal\";\nimport { useNavigate } from \"react-router-dom\";\nimport { useRef, useState } from \"react\";\nimport HeaderNavigator from \"./widgets/Navigator\";\nimport MusicDetail from \"../MusicDetail\";\nimport Condition from \"../Condition\";\nimport SearchHistory from \"./widgets/SearchHistory\";\nimport { addSearchHistory } from \"@/renderer/utils/search-history\";\nimport { useTranslation } from \"react-i18next\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { appUtil, appWindowUtil } from \"@shared/utils/renderer\";\nimport { musicDetailShownStore } from \"@renderer/components/MusicDetail/store\";\n\nexport default function AppHeader() {\n    const navigate = useNavigate();\n    const inputRef = useRef<HTMLInputElement>();\n    const [showSearchHistory, setShowSearchHistory] = useState(false);\n    const isHistoryFocusRef = useRef(false);\n\n    const isMiniMode = useAppConfig(\"private.minimode\");\n\n    const { t } = useTranslation();\n\n    if (!showSearchHistory) {\n        isHistoryFocusRef.current = false;\n    }\n\n    function onSearchSubmit() {\n        if (inputRef.current.value) {\n            search(inputRef.current.value);\n        }\n    }\n\n    function search(keyword: string) {\n        navigate(`/main/search/${encodeURIComponent(keyword)}`);\n        musicDetailShownStore.setValue(false);\n        addSearchHistory(keyword);\n        setShowSearchHistory(false);\n    }\n\n    return (\n        <div className=\"header-container\">\n            <div className=\"left-part\">\n                <div className=\"logo\">\n                    <SvgAsset iconName=\"logo\"></SvgAsset>\n                </div>\n                <HeaderNavigator></HeaderNavigator>\n                <div id=\"header-search\" className=\"header-search\">\n                    <input\n                        ref={inputRef}\n                        className=\"header-search-input\"\n                        placeholder={t(\"app_header.search_placeholder\")}\n                        maxLength={50}\n                        onClick={() => {\n                            setShowSearchHistory(true);\n                        }}\n                        onKeyDown={(key) => {\n                            if (key.key === \"Enter\") {\n                                onSearchSubmit();\n                            }\n                        }}\n                        onFocus={() => {\n                            setShowSearchHistory(true);\n                        }}\n                        onBlur={() => {\n                            setTimeout(() => {\n                                if (!isHistoryFocusRef.current) {\n                                    setShowSearchHistory(false);\n                                }\n                            }, 0);\n                        }}\n                    ></input>\n                    <div className=\"search-submit\" role=\"button\" onClick={onSearchSubmit}>\n                        <SvgAsset iconName=\"magnifying-glass\"></SvgAsset>\n                    </div>\n                    <Condition condition={showSearchHistory}>\n                        <SearchHistory\n                            onHistoryClick={(item) => {\n                                search(item);\n                                inputRef.current.value = item;\n                            }}\n                            onHistoryPanelBlur={() => {\n                                isHistoryFocusRef.current = false;\n                                setShowSearchHistory(false);\n                            }}\n                            onHistoryPanelFocus={() => {\n                                isHistoryFocusRef.current = true;\n                                setShowSearchHistory(true);\n                            }}\n                        ></SearchHistory>\n                    </Condition>\n                </div>\n            </div>\n\n            <div className=\"right-part\">\n                <div\n                    role=\"button\"\n                    className=\"header-button sparkles-icon\"\n                    onClick={() => {\n                        showModal(\"Sparkles\");\n                    }}\n                >\n                    <SvgAsset iconName=\"sparkles\"></SvgAsset>\n                </div>\n                <div\n                    role=\"button\"\n                    className=\"header-button\"\n                    title={t(\"app_header.theme\")}\n                    onClick={() => {\n                        navigate(\"/main/theme\");\n                        MusicDetail.hide();\n                    }}\n                >\n                    <SvgAsset iconName=\"t-shirt-line\"></SvgAsset>\n                </div>\n                <div\n                    role=\"button\"\n                    className=\"header-button\"\n                    title={t(\"app_header.settings\")}\n                    onClick={() => {\n                        navigate(\"/main/setting\");\n                        MusicDetail.hide();\n                    }}\n                >\n                    <SvgAsset iconName=\"cog-8-tooth\"></SvgAsset>\n                </div>\n                <div className=\"header-divider\"></div>\n                <div\n                    role=\"button\"\n                    title={t(\"app_header.minimode\")}\n                    className=\"header-button\"\n                    onClick={() => {\n                        appWindowUtil.setMinimodeWindow(!isMiniMode);\n                        if (!isMiniMode) {\n                            appWindowUtil.minMainWindow(true);\n                        }\n                    }}\n                >\n                    <SvgAsset iconName=\"picture-in-picture-line\"></SvgAsset>\n                </div>\n                <div\n                    role=\"button\"\n                    title={t(\"app_header.minimize\")}\n                    className=\"header-button\"\n                    onClick={() => {\n                        appWindowUtil.minMainWindow();\n                    }}\n                >\n                    <SvgAsset iconName=\"minus\"></SvgAsset>\n                </div>\n                <div role=\"button\" className=\"header-button\" onClick={() => {\n                    appWindowUtil.toggleMainWindowMaximize();\n                }}>\n                    <SvgAsset iconName=\"square\"></SvgAsset>\n                </div>\n                <div\n                    role=\"button\"\n                    title={t(\"app_header.exit\")}\n                    className=\"header-button\"\n                    onClick={() => {\n                        const exitBehavior = AppConfig.getConfig(\"normal.closeBehavior\");\n                        if (exitBehavior === \"minimize\") {\n                            appWindowUtil.minMainWindow(true);\n                        } else {\n                            appUtil.exitApp();\n                        }\n                    }}\n                >\n                    <SvgAsset iconName=\"x-mark\"></SvgAsset>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Header/widgets/Navigator/index.scss",
    "content": ".header-navigator {\n    height: 40px;\n    -webkit-app-region: none;\n    display: flex;\n    align-items: center;\n\n    & .navigator-btn {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      width: 26px;\n      height: 22px;\n      cursor: pointer;\n      color: var(--headerTextColor);\n      border: 1px solid var(--dividerColor);\n\n      &[data-disabled=\"true\"] {\n          color: var(--headerTextColor);\n          opacity: 0.5;\n          cursor: default;\n      }\n      & svg {\n        width: 14px;\n        height: 14px;\n      }\n    }\n  }"
  },
  {
    "path": "src/renderer/components/Header/widgets/Navigator/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport \"./index.scss\";\nimport { useNavigate } from \"react-router-dom\";\nimport MusicDetail, { isMusicDetailShown } from \"@/renderer/components/MusicDetail\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function HeaderNavigator() {\n    const navigate = useNavigate();\n    const canBack = history.state.idx > 0;\n    const canGo = history.state.idx < history.length - 1;\n\n    const { t } = useTranslation();\n\n\n    return (\n        <div className=\"header-navigator\">\n            <div\n                className=\"navigator-btn\"\n                data-disabled={!canBack}\n                title={canBack ? t(\"app_header.nav_back\") : undefined}\n                role=\"button\"\n                onClick={() => {\n                    if (isMusicDetailShown()) {\n                        MusicDetail.hide();\n                    } else {\n                        navigate(-1);\n                    }\n                }}\n            >\n                <SvgAsset iconName=\"chevron-left\"></SvgAsset>\n            </div>\n            <div\n                className=\"navigator-btn\"\n                data-disabled={!canGo}\n                title={canGo ? t(\"app_header.nav_forward\") : undefined}\n                onClick={() => {\n                    if (isMusicDetailShown()) {\n                        MusicDetail.hide();\n                    } else {\n                        navigate(1);\n                    }\n                }}\n            >\n                <SvgAsset iconName=\"chevron-right\"></SvgAsset>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Header/widgets/SearchHistory/index.scss",
    "content": ".search-history--container {\n  position: absolute;\n  box-sizing: border-box;\n  width: 100%;\n  height: 220px;\n  overflow-y: auto;\n  top: calc(100% + 4px);\n  left: 0;\n  border-radius: 4px;\n  padding: 12px;\n\n  & .search-history--header {\n    height: 1.6rem;\n    user-select: none;\n    font-weight: 500;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    margin-bottom: 10px;\n\n    & .search-history--header-clear {\n      $size: 1rem;\n      width: $size;\n      height: $size;\n\n      & svg {\n        width: $size;\n        height: $size;\n      }\n    }\n  }\n\n  & .search-history--body {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px 8px;\n  }\n\n  & .search-history--item[data-type=\"normalButton\"] {\n    font-size: 0.9rem;\n    padding-right: 0.6rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    & .search-history--item-remove {\n      width: 1rem;\n      height: 1rem;\n      margin-left: 6px;\n\n      & svg {\n        width: 1rem;\n        height: 1rem;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Header/widgets/SearchHistory/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport \"./index.scss\";\nimport { useEffect, useState } from \"react\";\nimport {\n    clearSearchHistory,\n    getSearchHistory,\n    removeSearchHistory,\n} from \"@/renderer/utils/search-history\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Empty from \"@/renderer/components/Empty\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ISearchHistoryProps {\n    onHistoryClick: (item: string) => void;\n    onHistoryPanelFocus?: () => void;\n    onHistoryPanelBlur?: () => void;\n}\n\nexport default function SearchHistory(props: ISearchHistoryProps) {\n    const { onHistoryClick, onHistoryPanelBlur, onHistoryPanelFocus } = props;\n    const [historyList, removeHistory] = useSearchHistory();\n    const { t } = useTranslation();\n\n\n    return (\n        <div\n            className=\"search-history--container backdrop-color shadow\"\n            tabIndex={-1}\n            onFocus={onHistoryPanelFocus}\n            onBlur={onHistoryPanelBlur}\n        >\n            <div className=\"search-history--header\">\n                {t(\"app_header.search_history\")}\n                <div\n                    className=\"search-history--header-clear\"\n                    role=\"button\"\n                    onClick={() => {\n                        removeHistory();\n                    }}\n                >\n                    <SvgAsset iconName=\"trash\"></SvgAsset>\n                </div>\n            </div>\n            <div className=\"search-history--body\">\n                <Condition\n                    condition={historyList?.length}\n                    falsy={\n                        <Empty\n                            style={{\n                                minHeight: \"100px\",\n                            }}\n                        ></Empty>\n                    }\n                >\n                    {historyList.map((historyItem) => (\n                        <div\n                            className=\"search-history--item\"\n                            key={historyItem}\n                            role=\"button\"\n                            data-type=\"normalButton\"\n                            onClick={() => {\n                                onHistoryClick?.(historyItem);\n                            }}\n                        >\n                            {historyItem}\n                            <div\n                                className=\"search-history--item-remove\"\n                                onClick={(e) => {\n                                    e.stopPropagation();\n                                    removeHistory(historyItem);\n                                }}\n                            >\n                                <SvgAsset iconName=\"x-mark\"></SvgAsset>\n                            </div>\n                        </div>\n                    ))}\n                </Condition>\n            </div>\n        </div>\n    );\n}\n\nfunction useSearchHistory() {\n    const [historyList, setHistoryList] = useState<string[]>([]);\n\n    function refreshHistoryList() {\n        getSearchHistory().then((res) => {\n            setHistoryList(res);\n        });\n    }\n\n    useEffect(() => {\n        refreshHistoryList();\n    }, []);\n\n    async function removeHistory(item?: string) {\n        if (!item) {\n            await clearSearchHistory();\n        } else {\n            await removeSearchHistory(item);\n        }\n        refreshHistoryList();\n    }\n\n    return [historyList, removeHistory] as const;\n}\n"
  },
  {
    "path": "src/renderer/components/Loading/index.scss",
    "content": ".loading-container {\n  width: 100%;\n  height: 100%;\n  min-height: 300px;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n\n  & .spinner-container {\n    display: inline-block;\n    position: relative;\n    width: 80px;\n    height: 80px;\n  }\n  & .spinner-container div {\n    display: inline-block;\n    position: absolute;\n    left: 8px;\n    width: 16px;\n    background: var(--primaryColor);\n    animation: spinning-animation 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite;\n  }\n  & .spinner-container div:nth-child(1) {\n    left: 8px;\n    animation-delay: -0.24s;\n  }\n  & .spinner-container div:nth-child(2) {\n    left: 32px;\n    animation-delay: -0.12s;\n  }\n  & .spinner-container div:nth-child(3) {\n    left: 56px;\n    animation-delay: 0;\n  }\n\n  @keyframes spinning-animation {\n    0% {\n      top: 8px;\n      height: 64px;\n    }\n    50%,\n    100% {\n      top: 24px;\n      height: 32px;\n    }\n  }\n\n  & span {\n    font-size: 1rem;\n    user-select: none;\n\n    &::after {\n      content: \"\";\n      animation: text-animation 1.2s linear infinite;\n    }\n  }\n\n  @keyframes text-animation {\n    from {\n      content: \" \";\n    }\n\n    25% {\n      content: \" ·\";\n    }\n\n    50% {\n      content: \" ··\";\n    }\n    75% {\n      content: \" ···\";\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Loading/index.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport \"./index.scss\";\n\ninterface ILoadingProps {\n    text?: string\n}\nexport default function Loading(props: ILoadingProps) {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"loading-container\">\n            <div className=\"spinner-container\">\n                <div></div>\n                <div></div>\n                <div></div>\n            </div>\n            <span>{props.text ?? t(\"common.loading\")}</span>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/index.tsx",
    "content": "import Store from \"@/common/store\";\nimport templates from \"./templates\";\nimport { useMemo } from \"react\";\n\ntype ITemplate = typeof templates;\ntype IModalType = keyof ITemplate;\n\ninterface IModalInfo {\n    type: IModalType | null;\n    payload: any;\n}\n\nconst modalStore = new Store<IModalInfo>({\n    type: null,\n    payload: null,\n});\n\nexport default function ModalComponent() {\n    const modalState = modalStore.useValue();\n\n    const component = useMemo(() => {\n        if (modalState.type) {\n            const Component = templates[modalState.type];\n            return <Component {...(modalState.payload ?? {})}></Component>;\n        }\n        return null;\n    }, [modalState]);\n\n    return component;\n}\n\nexport function showModal<T extends keyof ITemplate>(\n    type: T,\n    payload?: Parameters<ITemplate[T]>[0],\n) {\n    modalStore.setValue({\n        type,\n        payload,\n    });\n}\n\nexport function hideModal() {\n    modalStore.setValue({\n        type: null,\n        payload: null,\n    });\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/AddMusicToSheet/index.scss",
    "content": ".modal--add-music-to-sheet-container {\n  width: 400px;\n  max-height: 540px;\n  border-radius: 12px;\n  display: flex;\n  flex-direction: column;\n  padding-bottom: 12px;\n\n  & .components--modal-base-header {\n    & .music-length {\n      font-size: 1rem;\n      font-weight: normal;\n    }\n  }\n\n  & .music-sheets {\n    flex: 1;\n    overflow-y: auto;\n\n    & .sheet-item {\n      box-sizing: border-box;\n      height: 64px;\n      width: 100%;\n      padding-left: 1rem;\n      padding-right: 1rem;\n      display: flex;\n      align-items: center;\n      font-size: 1.1rem;\n      font-weight: 500;\n\n      &:hover {\n        background-color: var(--dividerColor);\n      }\n\n      & img {\n        width: 48px;\n        height: 48px;\n        border-radius: 8px;\n        object-fit: cover;\n        margin-right: 8px;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/AddMusicToSheet/index.tsx",
    "content": "import MusicSheet, { defaultSheet } from \"@/renderer/core/music-sheet\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport addImg from \"@/assets/imgs/add.png\";\nimport { hideModal, showModal } from \"../..\";\nimport { Trans, useTranslation } from \"react-i18next\";\n\ninterface IAddMusicToSheetProps {\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[];\n}\n\nexport default function AddMusicToSheet(props: IAddMusicToSheetProps) {\n    const { musicItems } = props;\n    const { t } = useTranslation();\n\n    const allSheets = MusicSheet.frontend.useAllSheets();\n    return (\n        <Base withBlur={false}>\n            <div className=\"modal--add-music-to-sheet-container shadow backdrop-color\">\n                <Base.Header>\n                    <span>\n                        {t(\"modal.add_to_my_sheets\")}{\" \"}\n                        <span className=\"music-length\">\n                            (\n                            <Trans\n                                i18nKey={\"modal.total_music_num\"}\n                                values={{\n                                    number: Array.isArray(musicItems) ? musicItems.length : 1,\n                                }}\n                            ></Trans>\n                            )\n                        </span>\n                    </span>\n                </Base.Header>\n                <div className=\"music-sheets\">\n                    <div\n                        className=\"sheet-item\"\n                        role=\"button\"\n                        onClick={() => {\n                            showModal(\"AddNewSheet\", {\n                                initMusicItems: musicItems,\n                            });\n                        }}\n                    >\n                        <img src={addImg}></img>\n                        <span>{t(\"modal.create_local_sheet\")}</span>\n                    </div>\n                    {allSheets.map((sheet) => (\n                        <div\n                            className=\"sheet-item\"\n                            key={sheet.id}\n                            role=\"button\"\n                            onClick={() => {\n                                MusicSheet.frontend.addMusicToSheet(musicItems, sheet.id);\n                                hideModal();\n                            }}\n                        >\n                            <img\n                                src={sheet.artwork ?? albumImg}\n                                onError={setFallbackAlbum}\n                            ></img>\n                            <span>\n                                {sheet.id === defaultSheet.id\n                                    ? t(\"media.default_favorite_sheet_name\")\n                                    : sheet.title}\n                            </span>\n                        </div>\n                    ))}\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/AddNewSheet/index.tsx",
    "content": "import { useCallback } from \"react\";\nimport MusicSheet from \"@/renderer/core/music-sheet\";\nimport debounce from \"@/common/debounce\";\nimport { hideModal } from \"../..\";\nimport SimpleInputWithState from \"../SimpleInputWithState\";\nimport { useTranslation } from \"react-i18next\";\nimport { CommonConst } from \"@/common/constant\";\n\ninterface IProps {\n    initMusicItems: IMusic.IMusicItem | IMusic.IMusicItem[];\n}\n\nexport default function AddNewSheet(props: IProps) {\n    const { t } = useTranslation();\n\n    const onCreateNewSheetClick = useCallback(\n        debounce(async (newSheetName) => {\n            try {\n                const newSheet = await MusicSheet.frontend.addSheet(newSheetName);\n                if (props?.initMusicItems) {\n                    await MusicSheet.frontend.addMusicToSheet(props.initMusicItems, newSheet.id);\n                }\n                hideModal();\n            } catch {\n                console.log(\"创建失败\");\n            }\n        }, 500),\n        [],\n    );\n\n    return (\n        <SimpleInputWithState\n            title={t(\"modal.create_local_sheet\")}\n            onOk={onCreateNewSheetClick}\n            placeholder={t(\"modal.create_local_sheet_placeholder\")}\n            maxLength={CommonConst.NEW_SHEET_NAME_LENGTH_LIMIT}\n            okText={t(\"common.create\")}\n        ></SimpleInputWithState>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Base/index.scss",
    "content": ".components--modal-base {\n  position: fixed;\n  z-index: 10010;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background-color: var(--maskColor);\n  cursor: default !important;\n\n  & .components--modal-base-header {\n    width: 100%;\n    display: flex;\n    height: 3rem;\n    box-sizing: border-box;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    align-items: center;\n    justify-content: space-between;\n    font-size: 1.2rem;\n    font-weight: 600;\n    user-select: none;\n    /* background-color: rgba($color: #000000, $alpha: 0.1); */\n    border-bottom: 1px solid var(--dividerColor);\n    flex-shrink: 0;\n\n    & .components--modal-base-header-close {\n      $size: 18px;\n      width: $size;\n      height: $size;\n\n      & svg {\n        width: $size;\n        height: $size;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Base/index.tsx",
    "content": "import { ReactNode, useRef } from \"react\";\nimport { hideModal } from \"../..\";\nimport \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\n\ninterface IBaseModalProps {\n    // 默认区域\n    onDefaultClick?: () => void;\n    // 点击默认区域时关闭\n    defaultClose?: boolean;\n    // 模糊\n    withBlur?: boolean;\n    children: ReactNode;\n}\n\nconst baseId = \"components--modal-base-container\";\n\nfunction Base(props: IBaseModalProps) {\n    const {\n        onDefaultClick,\n        defaultClose = false,\n        children,\n        withBlur = true,\n    } = props;\n\n    const trapCloseRef = useRef(false);\n\n    return (\n        <div\n            id={baseId}\n            className={`components--modal-base animate__animated animate__fadeIn ${withBlur ? \"blur10\" : \"\"\n            }`}\n            role=\"button\"\n            onMouseDown={(e) => {\n                if ((e.target as HTMLElement)?.id === baseId) {\n                    trapCloseRef.current = true;\n                } else {\n                    trapCloseRef.current = false;\n                }\n            }}\n            onMouseUp={(e) => {\n                if ((e.target as HTMLElement)?.id === baseId && trapCloseRef.current) {\n                    if (defaultClose) {\n                        hideModal();\n                    } else {\n                        onDefaultClick?.();\n                    }\n                }\n            }}\n            onMouseLeave={() => {\n                trapCloseRef.current = false;\n            }}\n            onMouseOut={() => {\n                trapCloseRef.current = false;\n            }}\n        >\n            {children}\n        </div>\n    );\n}\n\ninterface IHeaderProps {\n    children: ReactNode;\n}\nfunction Header(props: IHeaderProps) {\n    const { children } = props;\n\n    return (\n        <div className=\"components--modal-base-header\">\n            {children}\n            <div\n                role=\"button\"\n                className=\"components--modal-base-header-close opacity-button\"\n                onClick={() => {\n                    hideModal();\n                }}\n            >\n                <SvgAsset iconName=\"x-mark\"></SvgAsset>\n            </div>\n        </div>\n    );\n}\n\nBase.Header = Header;\nexport default Base;\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/ExitConfirm/index.scss",
    "content": ".modal--exit-confirm-container {\n    width: 440px;\n    height: 240px;\n    border-radius: 8px;\n}"
  },
  {
    "path": "src/renderer/components/Modal/templates/ExitConfirm/index.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\n\nexport default function ExitConfirm() {\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur>\n            <div className=\"modal--exit-confirm-container shadow backdrop-color\">\n                {t(\"modal.exit_confirm\")}\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/ImportMusicSheet/index.scss",
    "content": ".modal--import-music-sheet {\n  width: 360px;\n\n  & .content-container {\n    max-height: 540px;\n    min-height: 100px;\n  }\n\n  & .plugin-item {\n    box-sizing: border-box;\n    width: 100%;\n    height: 3rem;\n    line-height: 3rem;\n    font-size: 1rem;\n    padding-left: 1rem;\n    padding-right: 1rem;\n\n    &:hover {\n      background: var(--listHoverColor);\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/ImportMusicSheet/index.tsx",
    "content": "import { hideModal, showModal } from \"../..\";\nimport Base from \"../Base\";\nimport { toast } from \"react-toastify\";\nimport { useTranslation } from \"react-i18next\";\nimport \"./index.scss\";\nimport NoPlugin from \"@renderer/components/NoPlugin\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\ninterface IProps {\n    plugins: IPlugin.IPluginDelegate[];\n}\n\nexport default function ImportMusicSheet(props: IProps) {\n    const { plugins } = props;\n\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur={false}>\n            <div className=\"modal--import-music-sheet shadow backdrop-color\">\n                <Base.Header>{t(\"plugin.method_import_music_sheet\")}</Base.Header>\n                <div className=\"content-container\">\n                    {\n                        plugins?.length > 0 ? <>{plugins.map((it) => (\n                            <div\n                                role=\"button\"\n                                key={it.hash}\n                                className=\"plugin-item\"\n                                onClick={() => {\n                                    hideModal();\n                                    showModal(\"SimpleInputWithState\", {\n                                        title: t(\"plugin.method_import_music_sheet\"),\n                                        withLoading: true,\n                                        loadingText: t(\"plugin_management_page.importing_media\"),\n                                        placeholder: t(\n                                            \"plugin_management_page.placeholder_import_music_sheet\",\n                                            {\n                                                plugin: it.platform,\n                                            },\n                                        ),\n                                        maxLength: 1000,\n                                        onOk(text) {\n                                            return PluginManager.callPluginDelegateMethod(\n                                                it,\n                                                \"importMusicSheet\",\n                                                text.trim(),\n                                            );\n                                        },\n                                        onPromiseResolved(result) {\n                                            hideModal();\n                                            showModal(\"AddMusicToSheet\", {\n                                                musicItems: result as IMusic.IMusicItem[],\n                                            });\n                                        },\n                                        onPromiseRejected() {\n                                            toast.error(t(\"plugin_management_page.import_failed\"));\n                                        },\n                                        hints: it.hints?.importMusicSheet,\n                                    });\n                                }}\n                            >\n                                {it.platform}\n                            </div>\n                        ))}</> : <NoPlugin supportMethod={t(\"plugin.method_import_music_sheet\")}></NoPlugin>\n                    }\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/PluginSubscription/index.scss",
    "content": ".modal--plugin-subscription {\n  width: 420px;\n  border-radius: 12px;\n  display: flex;\n  flex-direction: column;\n  min-height: 164px;\n  max-height: 560px;\n\n  & .content-container {\n    font-size: 1.1rem;\n    flex: 1;\n    overflow: auto;\n\n    & .content-item {\n        margin: 1rem;\n        width: 360px;\n      & .content-item-row {\n        display: flex;\n        align-items: center;\n        \n        &:last-child {\n            margin-top: 0.5rem;\n        }\n\n        & span {\n            width: 4rem;\n        }\n\n        & input {\n            flex: 1;\n        }\n      }\n    }\n  }\n\n  & .opeartion-area {\n    flex-shrink: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    column-gap: 16px;\n    font-size: 1.1rem;\n    margin-bottom: 1rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/PluginSubscription/index.tsx",
    "content": "import {\n    getUserPreference,\n    setUserPreference,\n} from \"@/renderer/utils/user-perference\";\nimport { hideModal } from \"../..\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { ReactNode, useState } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Empty from \"@/renderer/components/Empty\";\nimport { toast } from \"react-toastify\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function PluginSubscription() {\n    const [subscription, setSubscription] = useState(\n        getUserPreference(\"subscription\") ?? [],\n    );\n\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur={false}>\n            <div className=\"modal--plugin-subscription shadow backdrop-color\">\n                <Base.Header>{t(\"modal.plugin_subscription\")}</Base.Header>\n                <div className=\"content-container\">\n                    <Condition condition={subscription.length} falsy={<Empty></Empty>}>\n                        {subscription.map((item, index) => (\n                            <div className=\"content-item\" key={index}>\n                                <div className=\"content-item-row\">\n                                    <span>{t(\"modal.subscription_remarks\")}</span>\n                                    <input\n                                        defaultValue={item.title ?? \"\"}\n                                        onChange={(e) => {\n                                            setSubscription((prev) => {\n                                                const newSub = [...prev];\n                                                newSub[index].title = e.target.value;\n                                                return newSub;\n                                            });\n                                        }}\n                                    ></input>\n                                </div>\n                                <div className=\"content-item-row\">\n                                    <span>{t(\"modal.subscription_links\")}</span>\n                                    <input\n                                        defaultValue={item.srcUrl ?? \"\"}\n                                        onChange={(e) => {\n                                            setSubscription((prev) => {\n                                                const newSub = [...prev];\n                                                newSub[index].srcUrl = e.target.value;\n                                                return newSub;\n                                            });\n                                        }}\n                                    ></input>\n                                </div>\n                            </div>\n                        ))}\n                    </Condition>\n                </div>\n                <div className=\"opeartion-area\">\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            setSubscription((prev) => [\n                                ...prev,\n                                {\n                                    title: \"\",\n                                    srcUrl: \"\",\n                                },\n                            ]);\n                        }}\n                    >\n                        {t(\"common.add\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"dangerButton\"\n                        data-fill={true}\n                        onClick={() => {\n                            setUserPreference(\n                                \"subscription\",\n                                subscription.filter((item) =>\n                                    item.srcUrl.match(/https?:\\/\\/.+\\.js(on)?/),\n                                ),\n                            );\n                            toast.success(t(\"modal.subscription_save_success\"));\n                            hideModal();\n                        }}\n                    >\n                        {t(\"common.save\")}\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Reconfirm/index.scss",
    "content": ".modal--reconfirm {\n  width: 420px;\n  border-radius: 12px;\n  display: flex;\n  flex-direction: column;\n  min-height: 164px;\n  max-height: 340px;\n\n  & .content-container { \n    font-size: 1.1rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex: 1;\n  }\n\n  & .opeartion-area {\n    flex-shrink: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    column-gap: 16px;\n    font-size: 1.1rem;\n    margin-bottom: 1rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Reconfirm/index.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { hideModal } from \"../..\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { ReactNode } from \"react\";\n\ninterface IReconfirmProps {\n    title: string;\n    content: ReactNode;\n    onConfirm?: () => void;\n    onCancel?: () => void;\n}\n\nexport default function Reconfirm(props: IReconfirmProps) {\n    const { title, content, onConfirm, onCancel } = props;\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur={false}>\n            <div className=\"modal--reconfirm shadow backdrop-color\">\n                <Base.Header>{title}</Base.Header>\n                <div className=\"content-container\">{content}</div>\n                <div className=\"opeartion-area\">\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            onCancel?.();\n                            hideModal();\n                        }}\n                    >\n                        {t(\"common.cancel\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"dangerButton\"\n                        data-fill={true}\n                        onClick={onConfirm}\n                    >\n                        {t(\"common.confirm\")}\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/hooks/searchResultStore.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport Store from \"@/common/store\";\n\nexport interface ISearchLyricResult {\n    data: ILyric.ILyricItem[];\n    state: RequestStateCode;\n    page: number;\n}\n\ninterface ISearchLyricStoreData {\n    query?: string;\n    // plugin - result\n    data: Record<string, ISearchLyricResult>;\n}\n\nexport default new Store<ISearchLyricStoreData>({ data: {} });"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/hooks/useSearchLyric.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { useCallback, useRef } from \"react\";\nimport searchResultStore from \"./searchResultStore\";\nimport { produce } from \"immer\";\nimport { useTranslation } from \"react-i18next\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\n\nexport default function () {\n    // 当前正在搜索\n    const currentQueryRef = useRef<string>(\"\");\n    const { t } = useTranslation();\n\n    /**\n     * query: 搜索词\n     * queryPage: 搜索页码\n     * pluginHash: 搜索条件\n     */\n    const search = useCallback(async function (\n        query?: string,\n        queryPage?: number,\n        pluginHash?: string,\n    ) {\n        /** 如果没有指定插件，就用所有插件搜索 */\n        console.log(\"SEARCH LRC\", query, queryPage);\n        let plugins: IPlugin.IPluginDelegate[] = [];\n        if (pluginHash) {\n            const tgtPlugin = PluginManager.getPluginByHash(pluginHash);\n            if (tgtPlugin) {\n                plugins = [tgtPlugin];\n            }\n        } else {\n            plugins = PluginManager.getSearchablePlugins(\"lyric\");\n        }\n        if (plugins.length === 0) {\n            searchResultStore.setValue(\n                produce(draft => {\n                    draft.data = {};\n                }),\n            );\n            return;\n        }\n        console.log(plugins);\n        // 使用选中插件搜素\n        plugins.forEach(async plugin => {\n            const _platform = plugin.platform;\n            const _hash = plugin.hash;\n            if (!_platform || !_hash) {\n                // 插件无效，此时直接进入结果页\n                searchResultStore.setValue(\n                    produce(draft => {\n                        draft.data = {};\n                    }),\n                );\n                return;\n            }\n\n            // 上一份搜索结果\n            const prevPluginResult =\n                searchResultStore.getValue().data[plugin.hash];\n            /** 上一份搜索还没返回/已经结束 */\n            if (\n                (prevPluginResult?.state & RequestStateCode.PENDING_FIRST_PAGE ||\n                    prevPluginResult?.state === RequestStateCode.FINISHED) &&\n                undefined === query\n            ) {\n                return;\n            }\n\n            // 是否是一次新的搜索\n            const newSearch =\n                query ||\n                prevPluginResult?.page === undefined ||\n                queryPage === 1;\n\n            // 本次搜索关键词\n            currentQueryRef.current = query =\n                query ?? searchResultStore.getValue().query ?? \"\";\n\n            /** 搜索的页码 */\n            const page =\n                queryPage ?? newSearch ? 1 : (prevPluginResult?.page ?? 0) + 1;\n            try {\n                searchResultStore.setValue(\n                    produce(draft => {\n                        const prevMediaResult = draft.data;\n                        prevMediaResult[_hash] = {\n                            state: newSearch\n                                ? RequestStateCode.PENDING_FIRST_PAGE\n                                : RequestStateCode.PENDING_REST_PAGE,\n                            // @ts-ignore\n                            data: newSearch\n                                ? []\n                                : prevMediaResult[_hash]?.data ?? [],\n                            page,\n                        };\n                    }),\n                );\n                const result = await PluginManager.callPluginDelegateMethod(plugin, \"search\", query, page, \"lyric\");\n                console.log(result);\n                /** 如果搜索结果不是本次结果 */\n                if (currentQueryRef.current !== query) {\n                    return;\n                }\n                /** 切换到结果页 */\n                if (!result) {\n                    throw new Error(t(\"modal.serach_lyric_result_empty\"));\n                }\n                searchResultStore.setValue(\n                    produce(draft => {\n                        const prevMediaResult = draft.data;\n\n                        const prevPluginResult: any = prevMediaResult[\n                            _hash\n                        ] ?? {\n                            data: [],\n                        };\n                        const currResult = result.data ?? [];\n\n                        prevMediaResult[_hash] = {\n                            state:\n                                result?.isEnd === false && result?.data?.length\n                                    ? RequestStateCode.PARTLY_DONE\n                                    : RequestStateCode.FINISHED,\n                            page,\n                            data: newSearch\n                                ? currResult\n                                : (prevPluginResult.data ?? []).concat(\n                                    currResult,\n                                ),\n                        };\n                        return draft;\n                    }),\n                );\n            } catch (e: any) {\n                /** 如果搜索结果不是本次结果 */\n                if (currentQueryRef.current !== query) {\n                    return;\n                }\n                searchResultStore.setValue(\n                    produce(draft => {\n                        const prevMediaResult = draft.data;\n                        const prevPluginResult = prevMediaResult[_hash] ?? {\n                            state: RequestStateCode.PARTLY_DONE,\n                            data: [] as ILyric.ILyricItem[],\n                        };\n\n                        prevPluginResult.state = RequestStateCode.PARTLY_DONE;\n                        return draft;\n                    }),\n                );\n            }\n        });\n    },\n    []);\n\n    return search;\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/index.scss",
    "content": ".modal--search-lyric-container {\n  width: 600px;\n  max-height: 540px;\n  border-radius: 8px;\n  display: flex;\n  flex-direction: column;\n\n  & .search-lyric-input-container {\n    margin-right: 24px;\n    flex: 1;\n    position: relative;\n\n    & .search-lyric-input {\n      width: 100%;\n      padding-right: 26px;\n    }\n\n    & .search-lyric-search {\n      position: absolute;\n      top: 0;\n      right: 0;\n      padding: 0 6px;\n      height: 100%;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n\n      & svg {\n        width: 1.1rem;\n        height: 1.1rem;\n      }\n    }\n  }\n\n  & .tab-list-container {\n    padding: 0 12px;\n    overflow-x: auto;\n  }\n\n  & .tab-panels-container {\n    & .tab-panel-container {\n      height: 320px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport useSearchLyric from \"./hooks/useSearchLyric\";\nimport searchResultStore from \"./hooks/searchResultStore\";\nimport { Tab } from \"@headlessui/react\";\nimport SearchResult from \"./searchResult\";\nimport { useTranslation } from \"react-i18next\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\ninterface IProps {\n    defaultTitle?: string;\n    musicItem?: IMusic.IMusicItem;\n    defaultExtra?: boolean;\n}\n\nexport default function SearchLyric(props: IProps) {\n    const { defaultTitle, musicItem } = props;\n\n    const [inputSearch, setInputSearch] = useState(defaultTitle ?? \"\");\n\n    const searchLyric = useSearchLyric();\n    const searchResults = searchResultStore.useValue();\n    const { t } = useTranslation();\n\n    const availablePlugins = PluginManager.getSearchablePlugins(\"lyric\");\n\n    useEffect(() => {\n        if (inputSearch) {\n            searchLyric(inputSearch);\n        }\n    }, []);\n\n    return (\n        <Base defaultClose withBlur={false}>\n            <div className=\"modal--search-lyric-container shadow backdrop-color\">\n                <Base.Header>\n                    <div className=\"search-lyric-input-container\">\n                        <input\n                            className=\"search-lyric-input\"\n                            placeholder={t(\"modal.search_lyric\")}\n                            value={inputSearch}\n                            onChange={(evt) => {\n                                setInputSearch(evt.target.value);\n                            }}\n                            onKeyDown={(key) => {\n                                if (key.key === \"Enter\") {\n                                    searchLyric(inputSearch);\n                                }\n                            }}\n                        ></input>\n                        <div\n                            className=\"search-lyric-search\"\n                            role=\"button\"\n                            onClick={() => {\n                                searchLyric(inputSearch);\n                            }}\n                        >\n                            <SvgAsset iconName=\"magnifying-glass\"></SvgAsset>\n                        </div>\n                    </div>\n                </Base.Header>\n                <Tab.Group>\n                    <Tab.List className=\"tab-list-container\">\n                        {availablePlugins.map((plugin) => (\n                            <Tab key={plugin.hash} as=\"div\" className=\"tab-list-item\">\n                                {plugin.platform}\n                            </Tab>\n                        ))}\n                    </Tab.List>\n                    <Tab.Panels className={\"tab-panels-container\"}>\n                        {availablePlugins.map((plugin) => (\n                            <Tab.Panel className=\"tab-panel-container\" key={plugin.hash}>\n                                <SearchResult\n                                    data={searchResults.data[plugin.hash]}\n                                    musicItem={musicItem}\n                                ></SearchResult>\n                            </Tab.Panel>\n                        ))}\n                    </Tab.Panels>\n                </Tab.Group>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/searchResult.scss",
    "content": ".search-result-container {\n    width: 100%;\n    height: 100%;\n\n    & .search-result-falsy-container {\n        width: 100%;\n        height: 100%;\n        box-sizing: border-box;\n        overflow-x: hidden;\n        overflow-y: auto;\n\n        & .lyric-item {\n            width: 100%;\n            height: 64px;\n            display: flex;\n            align-items: center;\n            padding: 0 24px;\n\n            &:hover {\n                background-color: var(--listHoverColor);\n            }\n\n            &:active {\n                background-color: var(--listActiveColor);\n            }\n\n            & img {\n                width: 48px;\n                height: 48px;\n                border-radius: 8px;\n            }\n\n            & .lyric-info {\n                flex: 1;\n                margin-left: 16px;\n\n                & .artist {\n                    margin-top: 8px;\n                    font-size: 0.9rem;\n                    opacity: 0.8;\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "src/renderer/components/Modal/templates/SearchLyric/searchResult.tsx",
    "content": "import { memo } from \"react\";\nimport { ISearchLyricResult } from \"./hooks/searchResultStore\";\nimport { If } from \"@/renderer/components/Condition\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport Empty from \"@/renderer/components/Empty\";\nimport \"./searchResult.scss\";\nimport { linkLyric } from \"@/renderer/core/link-lyric\";\nimport { getMediaPrimaryKey } from \"@/common/media-util\";\nimport { toast } from \"react-toastify\";\nimport { hideModal } from \"../..\";\nimport { useTranslation } from \"react-i18next\";\nimport trackPlayer from \"@renderer/core/track-player\";\n\ninterface ISearchResultProps {\n    data: ISearchLyricResult;\n    musicItem?: IMusic.IMusicItem;\n}\n\nfunction SearchResult(props: ISearchResultProps) {\n    const { data, musicItem } = props;\n\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"search-result-container\">\n            <If\n                condition={\n                    data?.state && data.state & RequestStateCode.PENDING_FIRST_PAGE\n                }\n            >\n                <If.Truthy>\n                    <Loading></Loading>\n                </If.Truthy>\n                <If.Falsy>\n                    <div className=\"search-result-falsy-container\">\n                        {\n                            <If condition={data?.data?.length}>\n                                <If.Truthy>\n                                    {(data?.data ?? []).map((it) => (\n                                        <div\n                                            className=\"lyric-item\"\n                                            key={getMediaPrimaryKey(it)}\n                                            role=\"button\"\n                                            onClick={async () => {\n                                                if (musicItem) {\n                                                    try {\n                                                        await linkLyric(musicItem, it);\n                                                        if (trackPlayer.isCurrentMusic(musicItem)) {\n                                                            trackPlayer.fetchCurrentLyric(true);\n                                                        }\n                                                        toast.success(t(\"modal.media_lyric_linked\"));\n                                                        hideModal();\n                                                    } catch (e) {\n                                                        toast.error(`${t(\"modal.media_lyric_link_failed\")} ${e?.message ?? e}`);\n                                                    }\n                                                }\n                                            }}\n                                        >\n                                            <img\n                                                src={it.artwork ?? albumImg}\n                                                onError={setFallbackAlbum}\n                                            ></img>\n                                            <div className=\"lyric-info\">\n                                                <div className=\"title\">{it.title}</div>\n                                                <div className=\"artist\">{it.artist}</div>\n                                            </div>\n                                        </div>\n                                    ))}\n                                </If.Truthy>\n                                <If.Falsy>\n                                    <Empty></Empty>\n                                </If.Falsy>\n                            </If>\n                        }\n                    </div>\n                </If.Falsy>\n            </If>\n        </div>\n    );\n}\n\nexport default memo(SearchResult, (prev, curr) => prev.data === curr.data);\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SelectOne/index.scss",
    "content": ".modal--select-one-container {\n  width: 400px;\n  max-height: 540px;\n  border-radius: 8px;\n  display: flex;\n  flex-direction: column;\n  & .modal--body-container {\n    flex: 1;\n    padding-left: 16px;\n    padding-right: 16px;\n    overflow: auto;\n\n    & .row-container {\n      height: 2.6rem;\n      display: flex;\n      align-items: center;\n\n      &[data-selected=\"true\"] {\n        color: var(--primaryColor);\n      }\n    }\n  }\n\n  & .footer-options {\n    border-top: 1px solid var(--dividerColor);\n    flex-shrink: 0;\n    height: 3rem;\n    display: flex;\n    align-items: center;\n    justify-content: end;\n    gap: 12px;\n    padding: 0.3rem 16px;\n\n    & .footer-extra {\n      display: flex;\n      align-items: center;\n      flex: 1;\n\n      & .checkbox {\n        width: 1rem;\n        height: 1rem;\n        border-radius: 2px;\n        border: 1px solid currentColor;\n        margin-right: 0.4rem;\n        position: relative;\n\n        & svg {\n          position: absolute;\n          width: 1rem;\n          height: 1rem;\n          left: 0;\n          top: 0;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SelectOne/index.tsx",
    "content": "import { useState } from \"react\";\nimport { hideModal } from \"../..\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport Condition from \"@/renderer/components/Condition\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IProps {\n    title: string;\n    choices: Array<{\n        label?: string;\n        value: any;\n    }>;\n    extra?: string; // 附加字段\n    onOk?: (value: any, extra?: boolean) => void;\n    defaultValue?: any;\n    defaultExtra?: boolean;\n}\n\nexport default function SelectOne(props: IProps) {\n    const { title, choices, onOk, defaultValue, extra, defaultExtra } = props;\n    const [selectedIndex, setSelectedIndex] = useState<number>(\n        defaultValue !== undefined\n            ? choices.findIndex((choice) => choice.value === defaultValue)\n            : -1,\n    );\n    const [extraChecked, setExtraChecked] = useState(defaultExtra ?? false);\n    const { t } = useTranslation();\n\n    return (\n        <Base defaultClose withBlur={false}>\n            <div className=\"modal--select-one-container shadow backdrop-color\">\n                <Base.Header>{title}</Base.Header>\n                <div className=\"modal--body-container\">\n                    {choices.map((choice, index) => (\n                        <div\n                            className=\"row-container\"\n                            key={choice.value}\n                            role=\"button\"\n                            data-selected={selectedIndex === index}\n                            onClick={() => {\n                                setSelectedIndex(index);\n                            }}\n                        >\n                            {choice.label ?? choice.value}\n                        </div>\n                    ))}\n                </div>\n                <div className=\"footer-options\">\n                    <Condition condition={extra}>\n                        <div\n                            className={classNames({\n                                \"footer-extra\": true,\n                                highlight: extraChecked,\n                            })}\n                            role=\"button\"\n                            onClick={() => {\n                                setExtraChecked((prev) => !prev);\n                            }}\n                        >\n                            <div className=\"checkbox\">\n                                <Condition condition={extraChecked}>\n                                    <SvgAsset iconName=\"check\"></SvgAsset>\n                                </Condition>\n                            </div>\n                            {extra}\n                        </div>\n                    </Condition>\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            hideModal();\n                        }}\n                    >\n                        {t(\"common.cancel\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"primaryButton\"\n                        data-disabled={selectedIndex === -1}\n                        onClick={async () => {\n                            onOk?.(choices[selectedIndex]?.value, extraChecked);\n                            hideModal();\n                        }}\n                    >\n                        {t(\"common.confirm\")}\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SimpleInputWithState/index.scss",
    "content": ".modal--simple-input-with-state {\n  width: 420px;\n  border-radius: 12px;\n  display: flex;\n  flex-direction: column;\n  min-height: 164px;\n  max-height: 340px;\n\n  & .input-area {\n    margin: 20px;\n    margin-left: 1rem;\n    box-sizing: border-box;\n\n    & input {\n      width: 100%;\n    }\n  }\n\n  & .opeartion-area {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    font-size: 1.1rem;\n  }\n\n  & .hint-area {\n    padding-left: 14px;\n    padding-right: 14px;\n    margin-bottom: 1rem;\n    flex: 1;\n    overflow-y: auto;\n\n    & li {\n      line-height: 2rem;\n      white-space: normal;\n      word-wrap: break-word;\n      word-break: break-all;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/SimpleInputWithState/index.tsx",
    "content": "import { ReactNode, useState } from \"react\";\nimport \"./index.scss\";\nimport Base from \"../Base\";\nimport useMounted from \"@/hooks/useMounted\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Loading from \"@/renderer/components/Loading\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface ISimpleInputWithStateProps<PromiseItem> {\n    title: string;\n    defaultValue?: string;\n    placeholder?: string;\n    hints?: ReactNode[];\n    maxLength?: number;\n    withLoading?: boolean; // 是否需要中间状态\n    okText?: string;\n    loadingText?: string;\n    onOk?: (text: string) => any;\n    onPromiseResolved?: (result: PromiseItem) => void;\n    onPromiseRejected?: (reason?: any) => void;\n}\n\nexport default function SimpleInputWithState<PromiseItem>(\n    props: ISimpleInputWithStateProps<PromiseItem>,\n) {\n    const {\n        title,\n        defaultValue,\n        placeholder,\n        hints,\n        maxLength,\n        withLoading,\n        okText,\n        loadingText,\n        onOk,\n        onPromiseRejected,\n        onPromiseResolved,\n    } = props;\n    const [loading, setLoading] = useState(false);\n    const [inputText, setInputText] = useState(defaultValue ?? \"\");\n    const isMounted = useMounted();\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur={false}>\n            <div className=\"modal--simple-input-with-state shadow backdrop-color\">\n                <Base.Header>{title}</Base.Header>\n                <Condition\n                    condition={!(loading && withLoading)}\n                    falsy={<Loading text={loadingText}></Loading>}\n                >\n                    <div className=\"input-area\">\n                        <input\n                            autoFocus\n                            placeholder={placeholder}\n                            onChange={(e) => {\n                                setInputText(e.target.value.slice(0, maxLength));\n                            }}\n                            value={inputText}\n                        ></input>\n                    </div>\n                    <div className=\"opeartion-area\">\n                        <div\n                            role=\"button\"\n                            data-type=\"primaryButton\"\n                            data-disabled={inputText.length === 0}\n                            onClick={() => {\n                                const result = onOk?.(inputText);\n                                if (withLoading) {\n                                    setLoading(true);\n                                }\n                                result\n                                    ?.then?.((res: any) => {\n                                        if (isMounted.current) {\n                                            onPromiseResolved?.(res);\n                                            setLoading(false);\n                                        }\n                                    })\n                                    ?.catch((e: any) => {\n                                        if (isMounted.current) {\n                                            onPromiseRejected?.(e);\n                                            setLoading(false);\n                                        }\n                                    });\n                            }}\n                        >\n                            {okText ?? t(\"common.confirm\")}\n                        </div>\n                    </div>\n                    <Condition condition={hints}>\n                        <div className=\"divider\"></div>\n                        <div className=\"hint-area\">\n                            {hints?.map((hint, index) => (\n                                <li key={index}>{hint}</li>\n                            ))}\n                        </div>\n                    </Condition>\n                </Condition>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Sparkles/index.scss",
    "content": ".modal--sparkles-container {\n  width: 600px;\n  height: 400px;\n  border-radius: 8px;\n  display: flex;\n  flex-direction: column;\n\n  & p {\n    line-height: 2rem;\n  }\n\n  & .img-container {\n    width: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    \n    & .wechat-channel {\n      display: block;\n      width: 120px;\n      height: 120px;\n    }\n  }\n\n  & .modal--body-container {\n    padding-left: 16px;\n    padding-right: 16px;\n    flex: 1;\n    overflow-y: auto;\n\n    & .footer {\n      width: 100%;\n      text-align: end;\n    }\n\n    & .secret {\n      font-size: 10px;\n      color: transparent;\n      user-select: none;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Sparkles/index.tsx",
    "content": "import A from \"@/renderer/components/A\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport wcChannelImg from \"@/assets/imgs/wechat_channel.jpg\";\n\nexport default function Sparkles() {\n  \n    return (\n        <Base withBlur defaultClose>\n            <div className=\"modal--sparkles-container shadow backdrop-color\">\n                <Base.Header>✨✨✨开发者的话</Base.Header>\n                <div className=\"modal--body-container\">\n                    <p>\n                        首先感谢你使用这款软件。开发这款软件的初衷首先是满足自己日常的需求，顺便分享出来，如果能对更多人有帮助那再好不过。\n                    </p>\n                    <p>\n                        桌面版诞生于安卓版，在开发安卓版本的过程中逐渐发现有些地方的设计不合理，有些地方的代码也不太好，然后想到桌面版可以扩展出更多好玩的东西，所以趁着换工作的间隙，肝出了这个桌面版（的半成品）。安卓版本可以<A href=\"https://github.com/maotoumao/MusicFree\">点击这里</A>，后续如果有些更新可能会放在公众号上，也可以点个关注。（偶尔也会在公众号发一些技术文章，或者写个日记之类的，反正就随意吧）\n                    </p>\n                    <div className=\"img-container\">\n                        <img src={wcChannelImg} className=\"wechat-channel\"></img>\n                    </div>\n                    <p>\n                        本软件完全免费，并基于GPL协议开源，仅供学习参考使用，不可用于商业目的。代码地址：\n                        <A href=\"https://github.com/maotoumao/MusicFreeDesktop\">Github</A>{\" \"}\n                        <A href=\"https://gitee.com/maotoumao/MusicFreeDesktop\">Gitee</A>。\n                    </p>\n                    <p>\n                        本软件仅仅是一个本地播放器，也可以通过插件扩展第三方源，插件可以完成包括播放、搜索在内的大部分功能；如果你是从第三方下载的插件，请一定谨慎识别这些插件的安全性，保护好自己。（注意：插件以及插件可能产生的数据与本软件无关，请使用者合理合法使用。）\n                    </p>\n                    <p>\n                        还请注意本软件只是个人的业余项目，距离正式版也有很长一段距离。如果你在找成熟稳定的音乐软件，可以考虑其他优秀的软件。当然我会一直维护，让它变得尽可能的完善一些。业余时间用爱发电，进度慢还请见谅。如果你想帮忙提交代码或者开发一些功能，欢迎联系我（公众号留言/发邮件都行）。\n                    </p>\n                    <p>\n                        最后，如果真的有人看到这里，希望这款软件可以帮到你，这也是这款软件存在的意义。\n                    </p>\n                    <p className=\"footer\">by: 猫头猫</p>\n                    <div className=\"secret\">\n                        但愿有一天，我可以不受客观因素约束，把足够的时间投入到我所热爱的事情中（猫猫叹气\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Update/index.scss",
    "content": ".modal--update-container {\n  width: 600px;\n  max-height: 400px;\n  border-radius: 8px;\n  display: flex;\n  flex-direction: column;\n\n  & .modal--body-container {\n    flex: 1;\n    padding-left: 16px;\n    padding-right: 16px;\n    overflow: auto;\n\n    & .version {\n        margin-top: 0.8rem;\n        font-weight: 600;\n    }\n\n    & p {\n        line-height: 1.5rem;\n    }\n  }\n\n  & .footer-options {\n    flex-shrink: 0;\n    height: 3rem;\n    display: flex;\n    align-items: center;\n    justify-content: end;\n    gap: 12px;\n    padding-right: 16px;\n    margin-bottom: 0.5rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/Update/index.tsx",
    "content": "import { setUserPreference } from \"@/renderer/utils/user-perference\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { hideModal } from \"../..\";\nimport { useTranslation } from \"react-i18next\";\nimport { shellUtil } from \"@shared/utils/renderer\";\n\ninterface IUpdateProps {\n    currentVersion: string;\n    update: ICommon.IUpdateInfo[\"update\"];\n}\nexport default function Update(props: IUpdateProps) {\n    const { currentVersion, update = {} as ICommon.IUpdateInfo[\"update\"] } =\n    props;\n\n    const { t } = useTranslation();\n\n    return (\n        <Base withBlur defaultClose>\n            <div className=\"modal--update-container shadow backdrop-color\">\n                <Base.Header>{t(\"modal.new_version_found\")}</Base.Header>\n                <div className=\"modal--body-container\">\n                    <div className=\"version highlight\">\n                        {t(\"modal.latest_version\")}\n                        {update.version}\n                    </div>\n                    <div className=\"version\">\n                        {t(\"modal.current_version\")}\n                        {currentVersion}\n                    </div>\n                    <div className=\"divider\"></div>\n                    {update.changeLog.map((item, index) => (\n                        <p key={index}>{item}</p>\n                    ))}\n                </div>\n                <div className=\"divider\"></div>\n                <div className=\"footer-options\">\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            setUserPreference(\"skipVersion\", update.version);\n                            hideModal();\n                        }}\n                    >\n                        {t(\"modal.skip_this_version\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"primaryButton\"\n                        onClick={() => {\n                            shellUtil.openExternal(update.download[0]);\n                        }}\n                    >\n                        {t(\"common.update\")}\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/WatchLocalDir/index.scss",
    "content": ".modal--watch-local-dir-container {\n  width: 500px;\n  border-radius: 8px;\n  display: flex;\n  flex-direction: column;\n\n  & .modal--body-container {\n    height: 280px;\n    padding-left: 16px;\n    padding-right: 16px;\n    padding-top: 12px;\n    padding-bottom: 12px;\n    display: flex;\n    flex-direction: column;\n\n    & .modal--body-container-title {\n      flex-shrink: 0;\n      display: flex;\n      align-items: center;\n      width: 100%;\n      justify-content: space-between;\n    }\n\n    & .modal--body-scan-content {\n      width: 100%;\n      margin-top: 12px;\n      flex-grow: 1;\n      border: 1px solid var(--dividerColor);\n      overflow-y: auto;\n    }\n\n    & .row-container {\n      height: 2.6rem;\n      padding: 0 8px;\n      display: flex;\n      align-items: center;\n\n      & .title {\n        white-space: nowrap;\n        margin: 0 6px;\n        flex: 1;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      & .delete-path {\n        color: #fc5f5f;\n        width: 20px;\n        height: 20px;\n        & svg {\n          width: 20px;\n          height: 20px;\n        }\n      }\n\n      &:hover {\n        background-color: var(--listHoverColor);\n      }\n\n      &:active {\n        background-color: var(--listActiveColor);\n      }\n    }\n  }\n\n  & .footer-options {\n    flex-shrink: 0;\n    height: 3rem;\n    display: flex;\n    align-items: center;\n    justify-content: end;\n    gap: 12px;\n    padding-right: 16px;\n    margin-bottom: 0.5rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/WatchLocalDir/index.tsx",
    "content": "import {\n    getUserPreferenceIDB,\n    setUserPreferenceIDB,\n} from \"@/renderer/utils/user-perference\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { hideModal } from \"../..\";\nimport { useEffect, useRef, useState } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Empty from \"@/renderer/components/Empty\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport Checkbox from \"@/renderer/components/Checkbox\";\nimport localMusic from \"@/renderer/core/local-music\";\nimport { useTranslation } from \"react-i18next\";\nimport { dialogUtil } from \"@shared/utils/renderer\";\n\n\nexport default function WatchLocalDir() {\n    // 全部的文件夹\n    const [localDirs, setLocalDirs] = useState<string[]>([]);\n    // 选中的文件夹\n    const [checkedDirs, setCheckedDirs] = useState(new Set<string>());\n    const changeLogRef = useRef(new Map<string, \"add\" | \"delete\">()); // key: path; value: op\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        (async () => {\n            const allDirs = (await getUserPreferenceIDB(\"localWatchDir\")) ?? [];\n            const checked =\n                (await getUserPreferenceIDB(\"localWatchDirChecked\")) ?? [];\n            const allDirsSet = new Set(allDirs);\n            const validChecked = checked.filter((it) => allDirsSet.has(it));\n            setLocalDirs([...allDirsSet]);\n            setCheckedDirs(new Set(validChecked));\n        })();\n    }, []);\n\n    return (\n        <Base defaultClose>\n            <div className=\"modal--watch-local-dir-container shadow backdrop-color\">\n                <Base.Header>{t(\"modal.scan_local_music\")}</Base.Header>\n                <div className=\"modal--body-container\">\n                    <div className=\"modal--body-container-title\">\n                        <span>{t(\"modal.scan_local_music_hint\")}</span>\n                        <div\n                            role=\"button\"\n                            data-type=\"normalButton\"\n                            onClick={async () => {\n                                const result = await dialogUtil.showOpenDialog({\n                                    title: t(\"modal.scan_local_music\"),\n                                    properties: [\"openDirectory\", \"createDirectory\"],\n                                });\n                                if (!result.canceled) {\n                                    const selected = result.filePaths[0];\n                                    if (!localDirs.includes(selected)) {\n                                        const changeLog = changeLogRef.current;\n                                        setCheckedDirs((prev) => {\n                                            return new Set([...prev, selected]);\n                                        });\n                                        setLocalDirs((prev) => [...prev, selected]);\n                                        changeLog.set(selected, \"add\");\n                                    }\n                                }\n                            }}\n                        >\n                            {t(\"modal.add_folder\")}\n                        </div>\n                    </div>\n                    <div className=\"modal--body-scan-content backdrop-color\">\n                        <Condition\n                            condition={localDirs.length}\n                            falsy={\n                                <Empty\n                                    style={{\n                                        minHeight: \"200px\",\n                                    }}\n                                ></Empty>\n                            }\n                        >\n                            {localDirs.map((item) => {\n                                const isChecked = checkedDirs.has(item);\n\n                                return (\n                                    <div\n                                        className=\"row-container\"\n                                        key={item}\n                                        onClick={() => {\n                                            setCheckedDirs((prev) => {\n                                                const changeLog = changeLogRef.current;\n                                                const itemChangeLog = changeLog.get(item);\n                                                const isChecked = prev.has(item);\n                                                // 如果此次没有任何变动，说明是旧有的，此时需要删除监听\n                                                if (!itemChangeLog) {\n                                                    changeLog.set(item, isChecked ? \"delete\" : \"add\");\n                                                } else if (\n                                                    (itemChangeLog === \"add\" && isChecked) ||\n                                                    (itemChangeLog === \"delete\" && !isChecked)\n                                                ) {\n                                                    changeLog.delete(item);\n                                                }\n\n                                                if (isChecked) {\n                                                    prev.delete(item);\n                                                } else {\n                                                    prev.add(item);\n                                                }\n                                                return new Set(prev);\n                                            });\n                                        }}\n                                    >\n                                        <Checkbox\n                                            checked={isChecked}\n                                            style={{\n                                                color: isChecked ? \"var(--primaryColor)\" : undefined,\n                                            }}\n                                        ></Checkbox>\n                                        <div className=\"title\">{item}</div>\n                                        <div\n                                            role=\"button\"\n                                            className=\"delete-path\"\n                                            onClick={(e) => {\n                                                e.stopPropagation();\n                                                const changeLog = changeLogRef.current;\n                                                const itemChangeLog = changeLog.get(item);\n                                                // 如果此次没有任何变动，说明是旧有的，此时需要删除监听\n                                                if (!itemChangeLog) {\n                                                    changeLog.set(item, \"delete\");\n                                                } else if (itemChangeLog === \"add\") {\n                                                    // 此次新增，但是被删掉了\n                                                    changeLog.delete(item);\n                                                    console.log(\"heredelete\", changeLog);\n                                                }\n\n                                                setLocalDirs((prev) =>\n                                                    prev.filter((it) => it !== item),\n                                                );\n                                                setCheckedDirs((prev) => {\n                                                    prev.delete(item);\n                                                    return new Set(prev);\n                                                });\n                                            }}\n                                        >\n                                            <SvgAsset iconName=\"trash\"></SvgAsset>\n                                        </div>\n                                    </div>\n                                );\n                            })}\n                        </Condition>\n                    </div>\n                </div>\n                <div className=\"footer-options\">\n                    <div\n                        role=\"button\"\n                        data-type=\"primaryButton\"\n                        onClick={async () => {\n                            setUserPreferenceIDB(\"localWatchDir\", localDirs);\n                            setUserPreferenceIDB(\"localWatchDirChecked\", [...checkedDirs]);\n                            localMusic.changeWatchPath(changeLogRef.current);\n                            hideModal();\n                        }}\n                    >\n                        {t(\"common.confirm\")}\n                    </div>\n                </div>\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Modal/templates/index.ts",
    "content": "import AddMusicToSheet from \"./AddMusicToSheet\";\nimport AddNewSheet from \"./AddNewSheet\";\nimport Base from \"./Base\";\nimport ExitConfirm from \"./ExitConfirm\";\nimport ImportMusicSheet from \"./ImportMusicSheet\";\nimport PluginSubscription from \"./PluginSubscription\";\nimport Reconfirm from \"./Reconfirm\";\nimport SearchLyric from \"./SearchLyric\";\nimport SelectOne from \"./SelectOne\";\nimport SimpleInputWithState from \"./SimpleInputWithState\";\nimport Sparkles from \"./Sparkles\";\nimport Update from \"./Update\";\nimport WatchLocalDir from \"./WatchLocalDir\";\n\nexport default {\n    Base,\n    ExitConfirm,\n    AddNewSheet,\n    AddMusicToSheet,\n    Sparkles,\n    SimpleInputWithState,\n    Reconfirm,\n    Update,\n    WatchLocalDir,\n    SelectOne,\n    PluginSubscription,\n    SearchLyric,\n    ImportMusicSheet,\n};"
  },
  {
    "path": "src/renderer/components/MusicBar/index.scss",
    "content": ".music-bar-container {\n  width: 100vw;\n  height: var(--appMusicBarHeight, 64px);\n  border-top: 1px solid var(--dividerColor);\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  user-select: none;\n  position: relative;\n  z-index: 4000;\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/index.tsx",
    "content": "import Slider from \"./widgets/Slider\";\nimport MusicInfo from \"./widgets/MusicInfo\";\nimport Controller from \"./widgets/Controller\";\nimport Extra from \"./widgets/Extra\";\n\nimport \"./index.scss\";\n\nexport default function MusicBar() {\n    return (\n        <div className=\"music-bar-container background-color\">\n            <Slider></Slider>\n            <MusicInfo></MusicInfo>\n            <Controller></Controller>\n            <Extra></Extra>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Controller/index.scss",
    "content": "// 控制区域\n.music-controller {\n  margin-left: 10px;\n  flex: 1;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n\n  & .controller-btn {\n    cursor: pointer;\n    border-radius: 50%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    margin-left: 8px;\n    margin-right: 8px;\n}\n\n  & .primary-btn {\n    background-color: var(--primaryColor);\n    color: white;\n\n  }\n\n  & .play-or-pause {\n    width: 40px;\n    height: 40px;\n\n    & svg {\n      width: 24px;\n      height: 24px;\n    }\n  }\n\n  & .skip {\n    width: 34px;\n    height: 34px;\n\n    & svg {\n      width: 28px;\n      height: 28px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Controller/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport \"./index.scss\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { useTranslation } from \"react-i18next\";\nimport { PlayerState } from \"@/common/constant\";\nimport { usePlayerState } from \"@renderer/core/track-player/hooks\";\n\nexport default function Controller() {\n    const playerState = usePlayerState();\n\n    const { t } = useTranslation();\n\n\n    return (\n        <div className=\"music-controller\">\n            <div className=\"skip controller-btn\" title={t(\"music_bar.previous_music\")} onClick={() => {\n                trackPlayer.skipToPrev();\n\n            }}>\n                <SvgAsset iconName=\"skip-left\"></SvgAsset>\n            </div>\n            <div\n                className=\"play-or-pause controller-btn primary-btn\"\n                onClick={() => {\n                    if(playerState === PlayerState.Playing) {\n                        trackPlayer.pause();\n                    } else {\n                        trackPlayer.resume();\n                    }\n                }}\n            >\n                <SvgAsset\n                    iconName={\n                        playerState !== PlayerState.Playing ? \"play\" : \"pause\"\n                    }\n                ></SvgAsset>\n            </div>\n            <div\n                className=\"skip controller-btn\"\n                title={t(\"music_bar.next_music\")}\n                onClick={() => {\n\n                    trackPlayer.skipToNext();\n                }}\n            >\n                <SvgAsset iconName=\"skip-right\"></SvgAsset>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Extra/index.scss",
    "content": "// 其他区域\n.music-extra {\n  width: 280px;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  margin-right: 12px;\n  flex-shrink: 0;\n  z-index: 300;\n\n  & .extra-btn {\n    cursor: pointer;\n    color: var(--textColor);\n    margin-left: 12px;\n    height: 32px;\n    position: relative;\n    display: flex;\n    align-items: center;\n\n    & .volume-bubble-container {\n      position: absolute;\n      z-index: 10;\n      width: 3rem;\n      height: 9rem;\n      bottom: 100%;\n      left: 50%;\n      transform: translateX(-50%);\n      cursor: default;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n\n      & .volume-slider-container {\n        height: 6rem;\n\n        & .rc-slider-handle-dragging {\n          box-shadow: 0 0 5px 5px var(--primaryColor);\n        }\n      }\n\n      & .volume-slider-tag {\n        font-size: 0.9rem;\n        margin-top: 6px;\n      }\n    }\n\n    & svg {\n      width: 22px;\n      height: 22px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Extra/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport \"./index.scss\";\nimport SwitchCase from \"@/renderer/components/SwitchCase\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { useRef, useState } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Slider from \"rc-slider\";\nimport { showModal } from \"@/renderer/components/Modal\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport { getCurrentPanel, hidePanel, showPanel } from \"@/renderer/components/Panel\";\nimport { useTranslation } from \"react-i18next\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { isCN } from \"@/shared/i18n/renderer\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { RepeatMode } from \"@/common/constant\";\nimport { useQuality, useRepeatMode, useSpeed, useVolume } from \"@renderer/core/track-player/hooks\";\nimport { appWindowUtil } from \"@shared/utils/renderer\";\nimport { musicDetailShownStore } from \"@renderer/components/MusicDetail/store\";\n\nexport default function Extra() {\n    const repeatMode = useRepeatMode();\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"music-extra\">\n            <QualityBtn></QualityBtn>\n            <SpeedBtn></SpeedBtn>\n            <VolumeBtn></VolumeBtn>\n            <LyricBtn></LyricBtn>\n            <div\n                className=\"extra-btn\"\n                onClick={() => {\n                    trackPlayer.toggleRepeatMode();\n                }}\n                title={\n                    repeatMode === RepeatMode.Loop\n                        ? t(\"media.music_repeat_mode_loop\")\n                        : repeatMode === RepeatMode.Queue\n                            ? t(\"media.music_repeat_mode_queue\")\n                            : t(\"media.music_repeat_mode_shuffle\")\n                }\n            >\n                <SwitchCase.Switch switch={repeatMode}>\n                    <SwitchCase.Case case={RepeatMode.Loop}>\n                        <SvgAsset iconName=\"repeat-song\"></SvgAsset>\n                    </SwitchCase.Case>\n                    <SwitchCase.Case case={RepeatMode.Queue}>\n                        <SvgAsset iconName=\"repeat-song-1\"></SvgAsset>\n                    </SwitchCase.Case>\n                    <SwitchCase.Case case={RepeatMode.Shuffle}>\n                        <SvgAsset iconName=\"shuffle\"></SvgAsset>\n                    </SwitchCase.Case>\n                </SwitchCase.Switch>\n            </div>\n            <div\n                className=\"extra-btn\"\n                title={t(\"media.playlist\")}\n                role=\"button\"\n                onClick={() => {\n                    if (getCurrentPanel()?.type === \"PlayList\") {\n                        hidePanel();\n                    } else {\n                        showPanel(\"PlayList\", {\n                            coverHeader: musicDetailShownStore.getValue(),\n                        });\n                    }\n                }}\n            >\n                <SvgAsset iconName=\"playlist\"></SvgAsset>\n            </div>\n        </div>\n    );\n}\n\nfunction VolumeBtn() {\n    const volume = useVolume();\n    const tmpVolumeRef = useRef<number | null>(null);\n    const [showVolumeBubble, setShowVolumeBubble] = useState(false);\n    const { t } = useTranslation();\n\n    return (\n        <div\n            className=\"extra-btn\"\n            role=\"button\"\n            onMouseOver={() => {\n                setShowVolumeBubble(true);\n            }}\n            onMouseOut={() => {\n                setShowVolumeBubble(false);\n            }}\n            onClick={(e) => {\n                if (tmpVolumeRef.current === null) {\n                    tmpVolumeRef.current = 0;\n                }\n                tmpVolumeRef.current =\n          tmpVolumeRef.current === volume\n              ? volume === 0\n                  ? 1\n                  : 0\n              : tmpVolumeRef.current;\n                trackPlayer.setVolume(tmpVolumeRef.current);\n                tmpVolumeRef.current = volume;\n            }}\n        >\n            <Condition condition={showVolumeBubble}>\n                <div\n                    className=\"volume-bubble-container shadow backdrop-color\"\n                    onClick={(e) => {\n                        e.stopPropagation();\n                    }}\n                >\n                    <div className=\"volume-slider-container\">\n                        <Slider\n                            vertical\n                            min={0}\n                            max={1}\n                            step={0.01}\n                            onChange={(val) => {\n                                trackPlayer.setVolume(val as number);\n                            }}\n                            value={volume}\n                            styles={{\n                                track: {\n                                    background: \"var(--primaryColor)\",\n                                },\n                                handle: {\n                                    height: 12,\n                                    width: 12,\n                                    marginLeft: -4,\n                                    borderColor: \"var(--primaryColor)\",\n                                },\n                                rail: {\n                                    background: \"#d8d8d8\",\n                                },\n                            }}\n                        ></Slider>\n                    </div>\n                    <div className=\"volume-slider-tag\">{(volume * 100).toFixed(0)}%</div>\n                </div>\n            </Condition>\n            <SvgAsset\n                title={volume === 0 ? t(\"music_bar.unmute\") : t(\"music_bar.mute\")}\n                iconName={volume === 0 ? \"speaker-x-mark\" : \"speaker-wave\"}\n            ></SvgAsset>\n        </div>\n    );\n}\n\nfunction SpeedBtn() {\n    const speed = useSpeed();\n    const [showSpeedBubble, setShowSpeedBubble] = useState(false);\n    const tmpSpeedRef = useRef<number | null>(null);\n    const { t } = useTranslation();\n\n    return (\n        <div\n            className=\"extra-btn\"\n            role=\"button\"\n            onMouseOver={() => {\n                setShowSpeedBubble(true);\n            }}\n            onMouseOut={() => {\n                setShowSpeedBubble(false);\n            }}\n            onClick={() => {\n                if (tmpSpeedRef.current === null || tmpSpeedRef.current === speed) {\n                    tmpSpeedRef.current = 1;\n                }\n\n                trackPlayer.setSpeed(tmpSpeedRef.current);\n                tmpSpeedRef.current = speed;\n            }}\n        >\n            <Condition condition={showSpeedBubble}>\n                <div\n                    className=\"volume-bubble-container shadow backdrop-color\"\n                    onClick={(e) => {\n                        e.stopPropagation();\n                    }}\n                >\n                    <div className=\"volume-slider-container\">\n                        <Slider\n                            vertical\n                            min={0.25}\n                            max={2}\n                            step={0.05}\n                            onChange={(val) => {\n                                trackPlayer.setSpeed(val as number);\n                            }}\n                            value={speed}\n                            trackStyle={{\n                                background: \"var(--primaryColor)\",\n                            }}\n                            handleStyle={{\n                                height: 12,\n                                width: 12,\n                                marginLeft: -4,\n                                borderColor: \"var(--primaryColor)\",\n                            }}\n                            railStyle={{\n                                background: \"#d8d8d8\",\n                            }}\n                        ></Slider>\n                    </div>\n                    <div className=\"volume-slider-tag\">{speed.toFixed(2)}x</div>\n                </div>\n            </Condition>\n            <SvgAsset\n                title={t(\"music_bar.playback_speed\")}\n                iconName={\"dashboard-speed\"}\n            ></SvgAsset>\n        </div>\n    );\n}\n\nfunction QualityBtn() {\n    const quality = useQuality();\n    const { t } = useTranslation();\n\n    return (\n        <div\n            className=\"extra-btn\"\n            role=\"button\"\n            onClick={() => {\n                showModal(\"SelectOne\", {\n                    title: t(\"music_bar.choose_music_quality\"),\n                    defaultValue: quality,\n                    defaultExtra: true,\n                    extra: t(\"music_bar.only_set_for_current_music\"),\n                    choices: [\n                        {\n                            value: \"low\",\n                            label: t(\"media.music_quality_low\"),\n                        },\n                        {\n                            value: \"standard\",\n                            label: t(\"media.music_quality_standard\"),\n                        },\n                        {\n                            value: \"high\",\n                            label: t(\"media.music_quality_high\"),\n                        },\n                        {\n                            value: \"super\",\n                            label: t(\"media.music_quality_super\"),\n                        },\n                    ],\n                    onOk(value, extra) {\n                        trackPlayer.setQuality(value as IMusic.IQualityKey);\n                        if (!extra) {\n                            AppConfig.setConfig({\n                                \"playMusic.defaultQuality\": value,\n                            });\n                        }\n                    },\n                });\n            }}\n        >\n            <SwitchCase.Switch switch={quality}>\n                <SwitchCase.Case case={\"low\"}>\n                    <SvgAsset\n                        title={t(\"media.music_quality_low\")}\n                        iconName={\"lq\"}\n                    ></SvgAsset>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={\"standard\"}>\n                    <SvgAsset\n                        title={t(\"media.music_quality_standard\")}\n                        iconName={\"sd\"}\n                    ></SvgAsset>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={\"high\"}>\n                    <SvgAsset\n                        title={t(\"media.music_quality_high\")}\n                        iconName={\"hq\"}\n                    ></SvgAsset>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={\"super\"}>\n                    <SvgAsset title={t(\"music_quality_super\")} iconName={\"sq\"}></SvgAsset>\n                </SwitchCase.Case>\n            </SwitchCase.Switch>\n        </div>\n    );\n}\n\nfunction LyricBtn() {\n    const enableDesktopLyric = useAppConfig(\"lyric.enableDesktopLyric\");\n    const { t } = useTranslation();\n\n    return (\n        <div\n            className={classNames({\n                \"extra-btn\": true,\n                highlight: enableDesktopLyric,\n            })}\n            role=\"button\"\n            onClick={async () => {\n                appWindowUtil.setLyricWindow(!enableDesktopLyric);\n            }}\n        >\n            <SvgAsset\n                iconName={isCN() ? \"lyric\" : \"lyric-en\"}\n                title={t(\"music_bar.desktop_lyric\")}\n            ></SvgAsset>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/MusicInfo/index.scss",
    "content": "$width: 290px;\n\n.music-info-outer-container {\n  width: $width;\n  height: 100%;\n  position: relative;\n  overflow: hidden;\n}\n\n.music-info-content-container {\n  position: relative;\n  height: 100%;\n  width: 100%;\n  display: flex;\n  align-items: center;\n  transition: transform 0.3s ease;\n\n  &[data-detail-shown=\"true\"] {\n    transform: translateY(-100%);\n  }\n}\n\n.music-info-operations-container {\n  box-sizing: border-box;\n  padding: 0 12px;\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  font-size: 1rem;\n\n  & div[role='button'] {\n    width: 22px;\n    height: 22px;\n  }\n\n  & .music-info-operation-divider {\n    height: 40%;\n    width: 1px;\n    background: var(--dividerColor);\n  }\n}\n\n// 左边的信息区域\n.music-info-container {\n  box-sizing: border-box;\n  position: relative;\n  display: flex;\n  align-items: center;\n  width: $width;\n  height: 48px;\n  /* border-right: 1px solid var(--dividerColor); */\n  padding-left: 10px;\n\n  & .open-detail {\n    width: 44px;\n    height: 44px;\n    color: rgba($color: white, $alpha: 0.5);\n    position: absolute;\n    left: 10px;\n    top: 2px;\n    transition: all 200ms linear;\n    opacity: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background-color: rgba($color: #000000, $alpha: 0.5);\n    border-radius: 4px;\n\n    & svg {\n      width: 28px;\n      height: 28px;\n    }\n\n    &:hover {\n      opacity: 1;\n      backdrop-filter: blur(5px);\n    }\n  }\n\n  & .music-cover {\n    width: 44px;\n    height: 44px;\n    object-fit: cover;\n    flex-shrink: 0;\n    border-radius: 4px;\n  }\n\n  & .music-info {\n    display: flex;\n    flex-direction: column;\n    justify-content: space-around;\n    margin-left: 10px;\n    width: 226px;\n    height: 48px;\n    padding: 8px 8px 8px 0;\n    font-size: 1rem;\n\n    & .music-title {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      font-size: 1.1rem;\n      & span {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n        cursor: pointer;\n      }\n    }\n\n    & .music-artist {\n      display: flex;\n      align-items: center;\n\n      & div {\n        opacity: 0.8;\n        overflow: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n        margin-right: 0.5rem;\n      }\n\n      & .artist {\n        flex: 1;\n      }\n\n      & .progress {\n        flex-shrink: 0;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/MusicInfo/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport \"./index.scss\";\n\nimport Tag from \"@/renderer/components/Tag\";\nimport { secondsToDuration } from \"@/common/time-util\";\nimport MusicFavorite from \"@/renderer/components/MusicFavorite\";\nimport MusicDetail, { useMusicDetailShown } from \"@/renderer/components/MusicDetail\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport { useTranslation } from \"react-i18next\";\nimport { useCurrentMusic, useProgress } from \"@renderer/core/track-player/hooks\";\nimport { hidePanel, showPanel } from \"@renderer/components/Panel\";\nimport MusicDownloaded from \"@renderer/components/MusicDownloaded\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function MusicInfo() {\n    const musicItem = useCurrentMusic();\n    const musicDetailShown = useMusicDetailShown();\n\n    const { t } = useTranslation();\n\n    function toggleMusicDetail() {\n        if (musicDetailShown) {\n            MusicDetail.hide();\n        } else {\n            MusicDetail.show();\n            hidePanel();\n        }\n    }\n\n    return (\n        <div className=\"music-info-outer-container\">\n            <div data-detail-shown={musicDetailShown} className=\"music-info-content-container\">\n                <div className=\"music-info-container\">\n                    {!musicItem ? null : (\n                        <>\n                            <img\n                                role=\"button\"\n                                className=\"music-cover\"\n                                crossOrigin=\"anonymous\"\n                                src={musicItem.artwork ?? albumImg}\n                                onError={setFallbackAlbum}\n                            ></img>\n\n                            <div\n                                className=\"open-detail\"\n                                role=\"button\"\n                                title={musicDetailShown ? t(\"music_bar.close_music_detail_page\") : t(\"music_bar.open_music_detail_page\")}\n                                onClick={toggleMusicDetail}\n                            >\n                                <SvgAsset\n                                    iconName={\n                                        musicDetailShown ? \"chevron-double-down\" : \"chevron-double-up\"\n                                    }\n                                ></SvgAsset>\n                            </div>\n                            <div className=\"music-info\">\n                                <div className=\"music-title\">\n                                    <span role=\"button\" onClick={toggleMusicDetail}\n                                        title={musicItem.title}>{musicItem.title}</span>\n                                    <Tag\n                                        fill\n                                        style={{\n                                            fontSize: \"0.9rem\",\n                                        }}\n                                    >\n                                        {musicItem.platform}\n                                    </Tag>\n                                </div>\n                                <div className=\"music-artist\">\n                                    <div className=\"artist\">{musicItem.artist}</div>\n                                    <Progress></Progress>\n                                    <MusicFavorite musicItem={musicItem} size={18}></MusicFavorite>\n                                </div>\n                            </div>\n                        </>\n                    )}\n                </div>\n            </div>\n            <div data-detail-shown={musicDetailShown}\n                className=\"music-info-content-container music-info-operations-container\">\n                <div\n                    className=\"open-detail\"\n                    role=\"button\"\n                    title={musicDetailShown ? t(\"music_bar.close_music_detail_page\") : t(\"music_bar.open_music_detail_page\")}\n                    onClick={toggleMusicDetail}\n                >\n                    <SvgAsset\n                        iconName={\n                            musicDetailShown ? \"chevron-double-down\" : \"chevron-double-up\"\n                        }\n                    ></SvgAsset>\n                </div>\n                <MusicFavorite musicItem={musicItem} size={22}></MusicFavorite>\n                <MusicDownloaded musicItem={musicItem} size={22}></MusicDownloaded>\n                <div role=\"button\"\n                    data-disabled={!PluginManager.isSupportFeatureMethod(musicItem?.platform, \"getMusicComments\")}\n                    onClick={() => {\n                        showPanel(\"MusicComment\", {\n                            musicItem: musicItem,\n                            coverHeader: true,\n                        });\n                    }}>\n                    <SvgAsset iconName=\"chat-bubble-left-ellipsis\" size={22}></SvgAsset>\n                </div>\n                <div className=\"music-info-operation-divider\"></div>\n                <Progress></Progress>\n            </div>\n        </div>\n    );\n}\n\nfunction Progress() {\n    const { currentTime, duration } = useProgress();\n    return (\n        <div className=\"progress\">\n            {isFinite(duration)\n                ? `${secondsToDuration(currentTime)}/${secondsToDuration(duration)}`\n                : null}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Slider/index.scss",
    "content": "@use \"sass:math\";\n\n.music-bar--slider-container {\n  position: absolute;\n  width: 100%;\n  left: 0;\n  $height: 12px;\n  top: - math.div($height, 2);\n  height: $height;\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  --slider-height: 2px;\n\n  &:hover,\n  &:active {\n    --slider-height: 6px;\n  }\n\n  & .bar {\n    width: 100%;\n    height: var(--slider-height);\n    transition: height 80ms linear;\n    background-color: #d8d8d8;\n  }\n\n  & .active-bar {\n    position: absolute;\n    width: 100%;\n    left: -100%;\n    height: var(--slider-height);\n    background-color: var(--primaryColor);\n    transform: translateX(0);\n    transition: height linear 80ms;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicBar/widgets/Slider/index.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport \"./index.scss\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { useProgress } from \"@renderer/core/track-player/hooks\";\n\nexport default function Slider() {\n    const [seekPercent, _setSeekPercent] = useState<number | null>(null);\n    const seekPercentRef = useRef<number | null>(null);\n    const { currentTime, duration } = useProgress();\n    const isPressedRef = useRef(false);\n\n    function setSeekPercent(value: number | null) {\n        _setSeekPercent(value);\n        seekPercentRef.current = value;\n    }\n\n    useEffect(() => {\n        const onMouseMove = (e: MouseEvent) => {\n            if (isPressedRef.current) {\n                setSeekPercent(Math.max(0, Math.min(1, e.clientX / window.innerWidth)));\n            }\n        };\n        const onMouseUp = (e: MouseEvent) => {\n            if (isPressedRef.current) {\n                isPressedRef.current = false;\n                const realProgress = trackPlayer.progress;\n                trackPlayer.seekTo(realProgress.duration * seekPercentRef.current);\n                setSeekPercent(null);\n            }\n        };\n        window.addEventListener(\"mousemove\", onMouseMove);\n        window.addEventListener(\"mouseup\", onMouseUp);\n        return () => {\n            window.removeEventListener(\"mousemove\", onMouseMove);\n            window.removeEventListener(\"mouseup\", onMouseUp);\n        };\n    }, []);\n    return (\n        <div\n            className=\"music-bar--slider-container\"\n            onMouseDown={(e) => {\n                if (isFinite(duration) && duration) {\n                    isPressedRef.current = true;\n                }\n            }}\n            onClick={(e) => {\n                if (isFinite(duration) && duration) {\n                    trackPlayer.seekTo((duration * e.clientX) / window.innerWidth);\n                }\n            }}\n        >\n            <div className=\"bar\"></div>\n            <div\n                className=\"active-bar\"\n                style={{\n                    transform: `translateX(${\n                        seekPercent !== null\n                            ? seekPercent * 100\n                            : duration === 0\n                                ? 0\n                                : !isFinite(duration) || isNaN(duration)\n                                    ? 0\n                                    : (currentTime / duration) * 100\n                    }%)`,\n                }}\n            ></div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/index.scss",
    "content": ".music-detail--container {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  z-index: 3000;\n  box-sizing: border-box;\n  padding-bottom: var(--appMusicBarHeight);\n  display: flex;\n  flex-direction: column;\n  -webkit-app-region: no-drag;\n\n  & .music-detail-background {\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-size: cover;\n    background-repeat: no-repeat;\n    position: absolute;\n    filter: blur(50px);\n    opacity: 0.5;\n    mask-image: linear-gradient(to bottom, #fff, transparent);\n    -webkit-mask-image: linear-gradient(to bottom, #fff, transparent);\n    z-index: -1;\n    transition: background-image ease 300ms;\n  }\n\n  & .hide-music-detail {\n    position: fixed;\n    right: 36px;\n    top: 36px;\n    $size: 28px;\n    width: $size;\n    height: $size;\n    & svg {\n      width: $size;\n      height: $size;\n    }\n  }\n\n  & .music-title {\n    height: 4.8rem;\n    line-height: 4.8rem;\n    text-align: center;\n    font-size: 2rem;\n    font-weight: 500;\n    margin-top: 1.5rem;\n    width: 80vw;\n    margin-left: 10vw;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n  }\n\n  & .music-info {\n    display: flex;\n    width: 70vw;\n    margin-left: 15vw;\n    font-size: 1.2rem;\n    justify-content: center;\n\n    & span {\n      opacity: 0.8;\n      overflow: hidden;\n      white-space: nowrap;\n      text-overflow: ellipsis;\n      margin-right: 1rem;\n      font-weight: 300;\n    }\n  }\n\n  & .music-body {\n    width: 100%;\n    box-sizing: border-box;\n    padding: 4rem 0;\n    height: 0;\n    flex: 1;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    column-gap: 54px;\n\n    & .music-album-options {\n      $size: 35vmin;\n      width: $size;\n      height: $size;\n      object-fit: cover;\n      -webkit-user-drag: none;\n      & .music-album {\n        width: $size;\n        height: $size;\n        border-radius: 8px;\n        object-fit: cover;\n      }\n\n      & .music-options {\n        margin-top: 18px;\n        height: 64px;\n        width: 100%;\n        display: flex;\n        gap: 12px;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/index.tsx",
    "content": "import AnimatedDiv from \"../AnimatedDiv\";\nimport \"./index.scss\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport Tag from \"../Tag\";\nimport { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport Header from \"./widgets/Header\";\nimport Lyric from \"./widgets/Lyric\";\nimport Condition from \"../Condition\";\nimport { useTranslation } from \"react-i18next\";\nimport { useCurrentMusic } from \"@renderer/core/track-player/hooks\";\nimport { useEffect } from \"react\";\nimport { musicDetailShownStore } from \"@renderer/components/MusicDetail/store\";\n\nexport const isMusicDetailShown = musicDetailShownStore.getValue;\nexport const useMusicDetailShown = musicDetailShownStore.useValue;\n\nfunction MusicDetail() {\n    const musicItem = useCurrentMusic();\n    const musicDetailShown = musicDetailShownStore.useValue();\n\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        const escHandler = (evt: KeyboardEvent) => {\n            if (evt.code === \"Escape\") {\n                evt.preventDefault();\n                musicDetailShownStore.setValue(false);\n            }\n        };\n        window.addEventListener(\"keydown\", escHandler);\n\n        return () => {\n            window.removeEventListener(\"keydown\", escHandler);\n        };\n    }, []);\n\n\n    return (\n        <AnimatedDiv\n            showIf={musicDetailShown}\n            className=\"music-detail--container animate__animated background-color\"\n            mountClassName=\"animate__slideInUp\"\n            unmountClassName=\"animate__slideOutDown\"\n            onAnimationEnd={() => {\n                // hack logic: https://github.com/electron/electron/issues/32341\n                // force reflow to refresh drag region\n                setTimeout(() => {\n                    document.body.style.width = \"0\";\n                    document.body.getBoundingClientRect();\n                    document.body.style.width = \"\";\n                }, 200);\n            }}\n        >\n            <div\n                className=\"music-detail-background\"\n                style={{\n                    backgroundImage: `url(${musicItem?.artwork ?? albumImg})`,\n                }}\n            ></div>\n            <Header></Header>\n            <div className=\"music-title\" title={musicItem?.title}>\n                {musicItem?.title || t(\"media.unknown_title\")}\n            </div>\n            <div className=\"music-info\">\n                <span>\n                    <Condition condition={musicItem?.artist}>\n                        {musicItem?.artist}\n                    </Condition>\n                    <Condition condition={musicItem?.album}>\n                        {\" \"}\n                        - {musicItem?.album}\n                    </Condition>\n                </span>\n                {musicItem?.platform ? <Tag fill>{musicItem.platform}</Tag> : null}\n            </div>\n            <div className=\"music-body\">\n                <div className=\"music-album-options\">\n                    <img\n                        className=\"music-album shadow\"\n                        onError={setFallbackAlbum}\n                        src={musicItem?.artwork ?? albumImg}\n                    ></img>\n                </div>\n\n                <Lyric></Lyric>\n            </div>\n        </AnimatedDiv>\n    );\n}\n\nMusicDetail.show = () => {\n    musicDetailShownStore.setValue(true);\n};\n\nMusicDetail.hide = () => {\n    musicDetailShownStore.setValue(false);\n};\n\nexport default MusicDetail;\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/store.ts",
    "content": "import Store from \"@/common/store\";\n\nexport const musicDetailShownStore = new Store(false);\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/widgets/Header/index.scss",
    "content": ".music-detail--header-container {\n  width: 100%;\n  height: var(--appHeaderHeight);\n  flex-shrink: 0;\n  -webkit-app-region: drag;\n\n  & .hide-music-detail {\n    left: 24px;\n    $size: 32px;\n    top: 16px;\n    width: $size;\n    height: $size;\n    -webkit-app-region: no-drag;\n    cursor: pointer;\n    & svg {\n      width: $size;\n      height: $size;\n    }\n  }\n\n  & .music-detail--header-right {\n    width: 100%;\n    height: 100%;\n    box-sizing: border-box;\n    display: flex;\n    align-items: center;\n    justify-content: flex-end;\n    padding-right: 16px;\n    gap: 4px;\n\n    & .header-button {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      width: 26px;\n      height: 20px;\n      opacity: 0.6;\n      cursor: pointer;\n      -webkit-app-region: no-drag;\n\n      &:hover {\n        opacity: 1;\n      }\n\n      & svg {\n        width: 20px;\n        height: 20px;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/widgets/Header/index.tsx",
    "content": "import \"./index.scss\";\nimport { musicDetailShownStore } from \"@renderer/components/MusicDetail/store\";\nimport SvgAsset from \"@renderer/components/SvgAsset\";\nimport { useTranslation } from \"react-i18next\";\nimport { appUtil, appWindowUtil } from \"@shared/utils/renderer\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\n\nexport default function Header() {\n    const { t } = useTranslation();\n\n\n    return <div className='music-detail--header-container' >\n        <div\n            className=\"hide-music-detail\"\n            role=\"button\"\n            title={t(\"music_bar.close_music_detail_page\")}\n            onClick={() => {\n                musicDetailShownStore.setValue(false);\n            }}\n        >\n            <SvgAsset iconName=\"chevron-down\"></SvgAsset>\n        </div>\n        <div className='music-detail--header-right'>\n            <div\n                role=\"button\"\n                title={t(\"app_header.minimize\")}\n                className=\"header-button\"\n                onClick={() => {\n                    appWindowUtil.minMainWindow();\n                }}\n            >\n                <SvgAsset iconName=\"minus\"></SvgAsset>\n            </div>\n            <div role=\"button\" className=\"header-button\" onClick={() => {\n                appWindowUtil.toggleMainWindowMaximize();\n            }}>\n                <SvgAsset iconName=\"square\"></SvgAsset>\n            </div>\n            <div\n                role=\"button\"\n                title={t(\"app_header.exit\")}\n                className=\"header-button\"\n                onClick={() => {\n                    const exitBehavior = AppConfig.getConfig(\"normal.closeBehavior\");\n                    if (exitBehavior === \"minimize\") {\n                        appWindowUtil.minMainWindow(true);\n                    } else {\n                        appUtil.exitApp();\n                    }\n                }}\n            >\n                <SvgAsset iconName=\"x-mark\"></SvgAsset>\n            </div>\n        </div>\n    </div>;\n\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/widgets/Lyric/index.scss",
    "content": "$width: 40vw;\n$height: 100%;\n.lyric-container-outer {\n  position: relative;\n  width: $width;\n  height: $height;\n\n  & .lyric-options-container {\n    position: absolute;\n    right: -14px;\n    bottom: 0;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n    transform: translateX(100%);\n\n    & .lyric-option-item {\n      &[data-active=\"true\"] {\n        color: var(--primaryColor);\n      }\n\n      width: 18px;\n      height: 18px;\n\n      & svg {\n        width: 100%;\n        height: 100%;\n      }\n    }\n  }\n}\n\n.lyric-container {\n  width: $width;\n  height: $height;\n  overflow-y: auto;\n  font-size: 1rem;\n  cursor: default;\n  position: relative;\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  &::-webkit-scrollbar-track {\n    background-color: transparent;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background-color: #999;\n    border-radius: 8px;\n  }\n\n  &:hover::-webkit-scrollbar {\n    display: block;\n  }\n\n  &[data-loading=\"false\"] {\n    &::before,\n    &::after {\n      content: \"\";\n      height: calc(50% - 0.5rem);\n      display: block;\n    }\n  }\n\n  & .lyric-item {\n    vertical-align: middle;\n    text-align: center;\n    padding-top: 0.6em;\n    padding-bottom: 0.6em;\n    font-size: 1em;\n    opacity: 0.8;\n\n    &[data-highlight=\"true\"] {\n      color: var(--primaryColor);\n      font-size: 1.2em;\n      font-weight: 600;\n      opacity: 1;\n    }\n  }\n\n  & .lyric-item-translation {\n    margin-top: -0.6em;\n  }\n\n  & .search-lyric {\n    color: var(--linkColor);\n    text-decoration: underline;\n  }\n}\n\n.lyric-ctx-menu--font-container {\n  height: 48px;\n  width: 100%;\n  display: flex;\n  align-items: center;\n\n  & .font-size-button {\n    width: 36px;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    flex-shrink: 0;\n    flex-grow: 0;\n\n    & svg {\n      width: 1.4rem;\n      height: 1.4rem;\n    }\n  }\n  & input {\n    flex: 1;\n    width: 0;\n    text-align: center;\n    margin: 0 12px;\n\n    &::-webkit-outer-spin-button,\n    &::-webkit-inner-spin-button {\n      -webkit-appearance: none;\n    }\n  }\n}\n\n.lyric-ctx-menu--row-container {\n  height: 36px;\n  width: 100%;\n  display: flex;\n  align-items: center;\n  padding-left: 12px;\n  padding-right: 12px;\n  box-sizing: border-box;\n\n  & span {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  &:hover {\n    background-color: var(--listHoverColor);\n  }\n}\n\n.lyric-ctx-menu--set-font-title {\n  margin-top: 12px;\n  font-weight: 600;\n  opacity: 0.6;\n  font-size: 1rem;\n  height: 14px;\n  padding-left: 12px;\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDetail/widgets/Lyric/index.tsx",
    "content": "import \"./index.scss\";\nimport Condition, { IfTruthy } from \"@/renderer/components/Condition\";\nimport Loading from \"@/renderer/components/Loading\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { showCustomContextMenu } from \"@/renderer/components/ContextMenu\";\nimport {\n    getUserPreference,\n    setUserPreference,\n    useUserPreference,\n} from \"@/renderer/utils/user-perference\";\nimport { toast } from \"react-toastify\";\nimport { showModal } from \"@/renderer/components/Modal\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport LyricParser from \"@/renderer/utils/lyric-parser\";\nimport { getLinkedLyric, unlinkLyric } from \"@/renderer/core/link-lyric\";\nimport { getMediaPrimaryKey } from \"@/common/media-util\";\nimport { useTranslation } from \"react-i18next\";\nimport { useLyric } from \"@renderer/core/track-player/hooks\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { dialogUtil, fsUtil } from \"@shared/utils/renderer\";\n\nexport default function Lyric() {\n    const lyricContext = useLyric();\n    const lyricParser = lyricContext?.parser;\n    const currentLrc = lyricContext?.currentLrc;\n\n    const containerRef = useRef<HTMLDivElement>();\n\n    const [fontSize, setFontSize] = useState<string | null>(\n        getUserPreference(\"inlineLyricFontSize\"),\n    );\n\n    const [showTranslation, setShowTranslation] =\n    useUserPreference(\"showTranslation\");\n    const { t } = useTranslation();\n\n    const mountRef = useRef(false);\n\n    useEffect(() => {\n        if (containerRef.current) {\n            const currentIndex = lyricContext?.currentLrc?.index;\n            if (currentIndex >= 0) {\n                const dom = document.querySelector(`#lyric-item-id-${currentIndex}`) as\n          | HTMLDivElement\n          | undefined;\n                if (dom) {\n                    const offsetTop =\n            dom.offsetTop -\n            containerRef.current.clientHeight / 2 +\n            dom.clientHeight / 2;\n                    containerRef.current.scrollTo({\n                        behavior: mountRef.current ? \"smooth\" : \"auto\",\n                        top: offsetTop,\n                    });\n                }\n            }\n        }\n        mountRef.current = true;\n    }, [currentLrc]);\n\n    const optionsComponent = (\n        <div className=\"lyric-options-container\">\n            <div\n                className=\"lyric-option-item\"\n                role=\"button\"\n                title={t(\"music_detail.translation\")}\n                data-active={\n                    !!showTranslation && (lyricParser?.hasTranslation ?? false)\n                }\n                data-disabled={!lyricParser?.hasTranslation}\n                onClick={() => {\n                    setShowTranslation(!showTranslation);\n                }}\n            >\n                <SvgAsset iconName=\"language\"></SvgAsset>\n            </div>\n        </div>\n    );\n\n    return (\n        <div className=\"lyric-container-outer\">\n            <div\n                className=\"lyric-container\"\n                data-loading={lyricContext === null}\n                onContextMenu={(e) => {\n                    showCustomContextMenu({\n                        x: e.clientX,\n                        y: e.clientY,\n                        width: 200,\n                        height: 146,\n                        component: (\n                            <LyricContextMenu\n                                setLyricFontSize={setFontSize}\n                                lyricParser={lyricParser}\n                            ></LyricContextMenu>\n                        ),\n                    });\n                }}\n                style={\n                    fontSize\n                        ? {\n                            fontSize: `${fontSize}px`,\n                        }\n                        : null\n                }\n                ref={containerRef}\n            >\n                {\n                    <Condition\n                        condition={lyricContext !== null}\n                        falsy={<Loading></Loading>}\n                    >\n                        <Condition\n                            condition={lyricParser}\n                            falsy={\n                                <>\n                                    <div className=\"lyric-item\">{t(\"music_detail.no_lyric\")}</div>\n                                    <div\n                                        className=\"lyric-item search-lyric\"\n                                        role=\"button\"\n                                        onClick={() => {\n                                            const currentMusic = trackPlayer.currentMusic;\n                                            showModal(\"SearchLyric\", {\n                                                defaultTitle: currentMusic?.title,\n                                                musicItem: currentMusic,\n                                            });\n                                        }}\n                                    >\n                                        {t(\"music_detail.search_lyric\")}\n                                    </div>\n                                </>\n                            }\n                        >\n                            {lyricParser?.getLyricItems?.()?.map((lyricItem, index) => (\n                                <>\n                                    <div\n                                        key={index}\n                                        className=\"lyric-item\"\n                                        id={`lyric-item-id-${index}`}\n                                        data-highlight={currentLrc?.index === index}\n                                    >\n                                        {lyricItem.lrc}\n                                    </div>\n                                    <IfTruthy\n                                        condition={lyricParser?.hasTranslation && showTranslation}\n                                    >\n                                        <div\n                                            key={\"tr\" + index}\n                                            className=\"lyric-item lyric-item-translation\"\n                                            id={`tr-lyric-item-id-${index}`}\n                                            data-highlight={currentLrc?.index === index}\n                                        >\n                                            {lyricItem.translation}\n                                        </div>\n                                    </IfTruthy>\n                                </>\n                            ))}\n                        </Condition>\n                    </Condition>\n                }\n            </div>\n            {optionsComponent}\n        </div>\n    );\n}\n\ninterface ILyricContextMenuProps {\n    setLyricFontSize: (val: string) => void;\n    lyricParser: LyricParser;\n}\n\nfunction LyricContextMenu(props: ILyricContextMenuProps) {\n    const { setLyricFontSize, lyricParser } = props;\n\n    const [fontSize, setFontSize] = useState<string | null>(\n        getUserPreference(\"inlineLyricFontSize\") ?? \"13\",\n    );\n    const [showTranslation, setShowTranslation] =\n    useUserPreference(\"showTranslation\");\n\n    const [linkedLyricInfo, setLinkedLyricInfo] = useState<IMedia.IUnique>(null);\n\n    const { t } = useTranslation();\n\n    const currentMusicRef = useRef<IMusic.IMusicItem>(\n        trackPlayer.currentMusic ?? ({} as any),\n    );\n\n    useEffect(() => {\n        if (currentMusicRef.current?.platform) {\n            getLinkedLyric(currentMusicRef.current).then((linked) => {\n                if (linked) {\n                    setLinkedLyricInfo(linked);\n                }\n            });\n        }\n    }, []);\n\n    function handleFontSize(val: string | number) {\n        if (val) {\n            const nVal = +val;\n            if (8 <= nVal && nVal <= 32) {\n                setUserPreference(\"inlineLyricFontSize\", `${val}`);\n                setLyricFontSize(`${val}`);\n            }\n        }\n    }\n\n    async function downloadLyric(fileType: \"lrc\" | \"txt\") {\n        let rawLrc = \"\";\n        if (fileType === \"lrc\") {\n            rawLrc = lyricParser.toString({\n                withTimestamp: true,\n            });\n        } else {\n            rawLrc = lyricParser.toString();\n        }\n\n        try {\n            const result = await dialogUtil.showSaveDialog({\n                title: t(\"music_detail.lyric_ctx_download_lyric\"),\n                defaultPath:\n          currentMusicRef.current.title +\n          (fileType === \"lrc\" ? \".lrc\" : \".txt\"),\n                filters: [\n                    {\n                        name: t(\"media.media_type_lyric\"),\n                        extensions: [\"lrc\", \"txt\"],\n                    },\n                ],\n            });\n            if (!result.canceled && result.filePath) {\n                await fsUtil.writeFile(result.filePath, rawLrc, \"utf-8\");\n                toast.success(t(\"music_detail.lyric_ctx_download_success\"));\n            } else {\n                throw new Error();\n            }\n        } catch {\n            toast.error(t(\"music_detail.lyric_ctx_download_fail\"));\n        }\n    }\n\n    return (\n        <>\n            <div className=\"lyric-ctx-menu--set-font-title\">\n                {t(\"music_detail.lyric_ctx_set_font_size\")}\n            </div>\n            <div\n                className=\"lyric-ctx-menu--font-container\"\n                onClick={(e) => e.stopPropagation()}\n            >\n                <div\n                    role=\"button\"\n                    className=\"font-size-button\"\n                    onClick={() => {\n                        if (fontSize) {\n                            setFontSize((prev) => {\n                                const newFontSize = +prev - 1;\n                                handleFontSize(newFontSize);\n                                if (newFontSize < 8) {\n                                    return \"8\";\n                                } else if (newFontSize > 32) {\n                                    return \"32\";\n                                }\n                                return `${newFontSize}`;\n                            });\n                        }\n                    }}\n                >\n                    <SvgAsset iconName=\"font-size-smaller\"></SvgAsset>\n                </div>\n                <input\n                    type=\"number\"\n                    max={32}\n                    min={8}\n                    value={fontSize}\n                    onChange={(e) => {\n                        const val = e.target.value;\n                        handleFontSize(val);\n                        setFontSize(e.target.value.trim());\n                    }}\n                ></input>\n                <div\n                    role=\"button\"\n                    className=\"font-size-button\"\n                    onClick={() => {\n                        if (fontSize) {\n                            setFontSize((prev) => {\n                                const newFontSize = +prev + 1;\n                                handleFontSize(newFontSize);\n                                if (newFontSize < 8) {\n                                    return \"8\";\n                                } else if (newFontSize > 32) {\n                                    return \"32\";\n                                }\n                                return `${newFontSize}`;\n                            });\n                        }\n                    }}\n                >\n                    <SvgAsset iconName=\"font-size-larger\"></SvgAsset>\n                </div>\n            </div>\n            <div className=\"divider\"></div>\n            <div\n                className=\"lyric-ctx-menu--row-container\"\n                role=\"button\"\n                data-disabled={!lyricParser?.hasTranslation}\n                onClick={() => {\n                    setShowTranslation(!showTranslation);\n                }}\n            >\n                {showTranslation\n                    ? t(\"music_detail.hide_translation\")\n                    : t(\"music_detail.show_translation\")}\n            </div>\n            <div\n                className=\"lyric-ctx-menu--row-container\"\n                role=\"button\"\n                data-disabled={!lyricParser}\n                onClick={() => {\n                    downloadLyric(\"lrc\");\n                }}\n            >\n                {t(\"music_detail.lyric_ctx_download_lyric_lrc\")}\n            </div>\n            <div\n                className=\"lyric-ctx-menu--row-container\"\n                role=\"button\"\n                data-disabled={!lyricParser}\n                onClick={() => {\n                    downloadLyric(\"txt\");\n                }}\n            >\n                {t(\"music_detail.lyric_ctx_download_lyric_txt\")}\n            </div>\n            <div className=\"divider\"></div>\n            <div\n                className=\"lyric-ctx-menu--row-container\"\n                role=\"button\"\n                onClick={() => {\n                    showModal(\"SearchLyric\", {\n                        defaultTitle: currentMusicRef.current.title,\n                        musicItem: currentMusicRef.current,\n                    });\n                }}\n            >\n                <span>\n                    {linkedLyricInfo\n                        ? `${t(\"music_detail.media_lyric_linked\")} ${getMediaPrimaryKey(\n                            linkedLyricInfo,\n                        )}`\n                        : t(\"music_detail.search_lyric\")}\n                </span>\n            </div>\n            <div\n                className=\"lyric-ctx-menu--row-container\"\n                role=\"button\"\n                data-disabled={!linkedLyricInfo}\n                onClick={async () => {\n                    try {\n                        await unlinkLyric(currentMusicRef.current);\n                        if (trackPlayer.isCurrentMusic(currentMusicRef.current)) {\n                            trackPlayer.fetchCurrentLyric(true);\n                        }\n                        toast.success(t(\"music_detail.toast_media_lyric_unlinked\"));\n                    } catch {\n                        // pass\n                    }\n                }}\n            >\n                {t(\"music_detail.unlink_media_lyric\")}\n            </div>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDownloaded/index.scss",
    "content": ".music-download-base {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.music-downloaded {\n  color: var(--infoColor, #0a95c8);\n}\n\n.music-can-download {\n  opacity: 0.6;\n\n  cursor: pointer;\n  &:hover {\n    opacity: 1;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicDownloaded/index.tsx",
    "content": "import { isSameMedia } from \"@/common/media-util\";\nimport SvgAsset, { SvgAssetIconNames } from \"@/renderer/components/SvgAsset\";\nimport { memo, useEffect, useState } from \"react\";\nimport \"./index.scss\";\nimport { DownloadState, localPluginName } from \"@/common/constant\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IMusicDownloadedProps {\n    musicItem: IMusic.IMusicItem;\n    size?: number;\n}\n\nfunction MusicDownloaded(props: IMusicDownloadedProps) {\n    const { musicItem, size = 18 } = props;\n    // const [loading, setLoading] = useState(false);\n\n    const downloadState = Downloader.useDownloadState(musicItem);\n\n    const { t } = useTranslation();\n    const isDownloadedOrLocal =\n    downloadState === DownloadState.DONE ||\n    musicItem?.platform === localPluginName;\n\n    let iconName: SvgAssetIconNames = \"array-download-tray\";\n\n    if (isDownloadedOrLocal) {\n        iconName = \"check-circle\";\n    } else if (\n        downloadState !== DownloadState.NONE &&\n    downloadState !== DownloadState.ERROR\n    ) {\n        iconName = \"rolling-1s\";\n    }\n\n    return (\n        <div\n            className={`music-download-base ${\n                isDownloadedOrLocal ? \"music-downloaded\" : \"music-can-download\"\n            }`}\n            title={\n                isDownloadedOrLocal ? t(\"common.downloaded\") : t(\"common.download\")\n            }\n            onClick={() => {\n                if (\n                    musicItem && (downloadState === DownloadState.NONE ||\n                downloadState === DownloadState.ERROR)\n                ) {\n                    Downloader.startDownload(musicItem);\n                }\n            }}\n        >\n            <SvgAsset iconName={iconName} size={size}></SvgAsset>\n        </div>\n    );\n}\n\nexport default memo(MusicDownloaded, (prev, curr) =>\n    isSameMedia(prev.musicItem, curr.musicItem),\n);\n"
  },
  {
    "path": "src/renderer/components/MusicFavorite/index.tsx",
    "content": "import SvgAsset from \"../SvgAsset\";\nimport MusicSheet from \"@/renderer/core/music-sheet\";\n\ninterface IMusicFavoriteProps {\n    musicItem: IMusic.IMusicItem;\n    size: number;\n}\n\nexport default function MusicFavorite(props: IMusicFavoriteProps) {\n    const { musicItem, size } = props;\n    const isFav = MusicSheet.frontend.useMusicIsFavorite(musicItem);\n\n    return (\n        <div\n            role=\"button\"\n            onClick={(e) => {\n                e.stopPropagation();\n                if (isFav) {\n                    MusicSheet.frontend.removeMusicFromFavorite(musicItem);\n                } else {\n                    MusicSheet.frontend.addMusicToFavorite(musicItem);\n                }\n            }}\n            onDoubleClick={(e) => {\n                e.stopPropagation();\n            }}\n            style={{\n                color: isFav ? \"red\" : \"var(--textColor)\",\n                width: size,\n                height: size,\n            }}\n        >\n            <SvgAsset\n                iconName={isFav ? \"heart\" : \"heart-outline\"}\n                size={size}\n            ></SvgAsset>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicList/index.scss",
    "content": ".music-list-container {\n  width: 100%;\n  min-height: 300px;\n\n  &:focus-visible {\n    outline: none;\n  }\n\n  & th {\n    position: relative;\n\n    &:not([data-id=\"like\"]):hover {\n      background-color: var(--listHoverColor);\n\n      & .sort-container[data-sorting=\"false\"] {\n        opacity: 0.6;\n      }\n    }\n\n    &:nth-child(2) {\n      text-align: center;\n    }\n\n    & .sort-container {\n      position: absolute;\n      right: 2px;\n      top: 50%;\n      transform: translateY(-50%);\n      width: 1rem;\n      height: 1rem;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      opacity: 0.8;\n\n      & svg {\n        width: 0.6rem;\n        height: 0.6rem;\n      }\n\n      &[data-sorting=\"false\"] {\n        opacity: 0;\n      }\n    }\n  }\n\n  & tr {\n    position: relative;\n  }\n\n  & td {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n\n    &:nth-child(2) {\n      text-align: center;\n    }\n  }\n\n  & .music-list-operations {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n  }\n\n  // & .resizer {\n  //   position: absolute;\n  //   right: 0;\n  //   top: 0;\n  //   height: 100%;\n  //   width: 4px;\n  //   cursor: col-resize;\n  //   user-select: none;\n  //   touch-action: none;\n  //   opacity: 0;\n\n  //   &:hover {\n  //     opacity: 1;\n  //   }\n  // }\n\n  // & .resizer-resizing {\n  //   opacity: 1;\n  // }\n}\n\n.music-list-drag-receiver {\n  position: absolute;\n  left: 0;\n  height: 12px;\n  width: 100%;\n  display: flex;\n  align-items: center;\n\n  & .music-list-drag-receiver-content {\n    width: 100%;\n    height: 2px;\n    background-color: var(--primaryColor);\n    pointer-events: none;\n  }\n}\n\n.music-list-drag-receiver-top {\n  top: -6px;\n}\n\n.music-list-drag-receiver-bottom {\n  bottom: -6px;\n}\n"
  },
  {
    "path": "src/renderer/components/MusicList/index.tsx",
    "content": "import {\n    ColumnDef,\n    createColumnHelper,\n    flexRender,\n    getCoreRowModel,\n    getSortedRowModel,\n    SortingState,\n    useReactTable,\n} from \"@tanstack/react-table\";\n\nimport \"./index.scss\";\nimport Tag from \"../Tag\";\nimport { secondsToDuration } from \"@/common/time-util\";\nimport MusicSheet from \"@/renderer/core/music-sheet\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport Condition, { IfTruthy } from \"../Condition\";\nimport Empty from \"../Empty\";\nimport MusicFavorite from \"../MusicFavorite\";\nimport MusicDownloaded from \"../MusicDownloaded\";\nimport { localPluginName, RequestStateCode } from \"@/common/constant\";\nimport BottomLoadingState from \"../BottomLoadingState\";\nimport { IContextMenuItem, showContextMenu } from \"../ContextMenu\";\nimport { getInternalData, getMediaPrimaryKey, isSameMedia } from \"@/common/media-util\";\nimport { CSSProperties, memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { showModal } from \"../Modal\";\nimport useVirtualList from \"@/hooks/useVirtualList\";\nimport hotkeys from \"hotkeys-js\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport { toast } from \"react-toastify\";\nimport SwitchCase from \"../SwitchCase\";\nimport SvgAsset from \"../SvgAsset\";\nimport musicSheetDB from \"@/renderer/core/db/music-sheet-db\";\nimport DragReceiver, { startDrag } from \"../DragReceiver\";\nimport { i18n } from \"@/shared/i18n/renderer\";\nimport isLocalMusic from \"@/renderer/utils/is-local-music\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { shellUtil } from \"@shared/utils/renderer\";\n\ninterface IMusicListProps {\n    /** 展示的播放列表 */\n    musicList: IMusic.IMusicItem[];\n    /** 实际的播放列表 */\n    getAllMusicItems?: () => IMusic.IMusicItem[];\n    /** 音乐列表所属的歌单信息 */\n    musicSheet?: IMusic.IMusicSheetItem;\n    // enablePagination?: boolean; // 分页/虚拟长列表\n    state?: RequestStateCode; // 网络状态\n    doubleClickBehavior?: \"replace\" | \"normal\"; // 双击行为\n    onPageChange?: (page?: number) => void; // 分页\n    /** 虚拟滚动参数 */\n    virtualProps?: {\n        offsetHeight?: number | (() => number); // 距离顶部的高度\n        getScrollElement?: () => HTMLElement; // 滚动\n        fallbackRenderCount?: number;\n    };\n    containerStyle?: CSSProperties;\n    hideRows?: Array<\n        \"like\" | \"index\" | \"title\" | \"artist\" | \"album\" | \"duration\" | \"platform\"\n    >;\n    /** 允许拖拽 */\n    enableDrag?: boolean;\n    /** 拖拽结束 */\n    onDragEnd?: (newMusicList: IMusic.IMusicItem[]) => void;\n    /** context */\n    contextMenu?: IContextMenuItem[];\n}\n\nconst columnHelper = createColumnHelper<IMusic.IMusicItem>();\nconst columnDef: ColumnDef<IMusic.IMusicItem>[] = [\n    columnHelper.display({\n        id: \"like\",\n        size: 42,\n        minSize: 42,\n        maxSize: 42,\n        cell: (info) => (\n            <div className=\"music-list-operations\">\n                <MusicFavorite musicItem={info.row.original} size={18}></MusicFavorite>\n                <MusicDownloaded musicItem={info.row.original}></MusicDownloaded>\n            </div>\n        ),\n        enableResizing: false,\n        enableSorting: false,\n    }),\n    columnHelper.accessor((_, index) => index + 1, {\n        cell: (info) => info.getValue(),\n        header: \"#\",\n        id: \"index\",\n        minSize: 40,\n        maxSize: 40,\n        size: 40,\n        enableResizing: false,\n    }),\n    columnHelper.accessor(\"title\", {\n        header: () => i18n.t(\"media.media_title\"),\n        size: 250,\n        maxSize: 300,\n        minSize: 100,\n        cell: (info) => {\n            const title = info?.getValue?.();\n            return <span title={title}>{title}</span>;\n        },\n        // @ts-ignore\n        fr: 3,\n    }),\n\n    columnHelper.accessor(\"artist\", {\n        header: () => i18n.t(\"media.media_type_artist\"),\n        size: 130,\n        maxSize: 200,\n        minSize: 60,\n        cell: (info) => <span title={info.getValue()}>{info.getValue()}</span>,\n        // @ts-ignore\n        fr: 2,\n    }),\n    columnHelper.accessor(\"album\", {\n        header: () => i18n.t(\"media.media_type_album\"),\n        size: 120,\n        maxSize: 200,\n        minSize: 60,\n        cell: (info) => <span title={info.getValue()}>{info.getValue()}</span>,\n        // @ts-ignore\n        fr: 2,\n    }),\n    columnHelper.accessor(\"duration\", {\n        header: () => i18n.t(\"media.media_duration\"),\n        size: 64,\n        maxSize: 150,\n        minSize: 48,\n        cell: (info) =>\n            info.getValue() ? secondsToDuration(info.getValue()) : \"--:--\",\n        // @ts-ignore\n        fr: 1,\n    }),\n    columnHelper.accessor(\"platform\", {\n        header: () => i18n.t(\"media.media_platform\"),\n        size: 100,\n        minSize: 80,\n        maxSize: 300,\n        cell: (info) => <Tag fill>{info.getValue()}</Tag>,\n        // @ts-ignore\n        fr: 1,\n    }),\n];\n\nconst estimizeItemHeight = 2.6 * 13; // lineheight 2.6rem\n\nexport function showMusicContextMenu(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    x: number,\n    y: number,\n    sheetType?: string,\n) {\n    const menuItems: IContextMenuItem[] = [];\n    const isArray = Array.isArray(musicItems);\n    if (!isArray) {\n        menuItems.push(\n            {\n                title: `ID: ${getMediaPrimaryKey(musicItems)}`,\n                icon: \"identification\",\n            },\n            {\n                title: `${i18n.t(\"media.media_type_artist\")}: ${\n                    musicItems.artist ?? i18n.t(\"media.unknown_artist\")\n                }`,\n                icon: \"user\",\n            },\n            {\n                title: `${i18n.t(\"media.media_type_album\")}: ${\n                    musicItems.album ?? i18n.t(\"media.unknown_album\")\n                }`,\n                icon: \"album\",\n                show: !!musicItems.album,\n            },\n            {\n                divider: true,\n            },\n        );\n    }\n    menuItems.push(\n        {\n            title: i18n.t(\"music_list_context_menu.next_play\"),\n            icon: \"motion-play\",\n            onClick() {\n                trackPlayer.addNext(musicItems);\n            },\n        },\n        {\n            title: i18n.t(\"music_list_context_menu.add_to_my_sheets\"),\n            icon: \"document-plus\",\n            onClick() {\n                showModal(\"AddMusicToSheet\", {\n                    musicItems: musicItems,\n                });\n            },\n        },\n        {\n            title: i18n.t(\"music_list_context_menu.remove_from_sheet\"),\n            icon: \"trash\",\n            show: !!sheetType && sheetType !== \"play-list\",\n            onClick() {\n                MusicSheet.frontend.removeMusicFromSheet(musicItems, sheetType);\n            },\n        },\n        {\n            title: i18n.t(\"common.remove\"),\n            icon: \"trash\",\n            show: sheetType === \"play-list\",\n            onClick() {\n                trackPlayer.removeMusic(musicItems);\n            },\n        },\n    );\n\n    menuItems.push(\n        {\n            title: i18n.t(\"common.download\"),\n            icon: \"array-download-tray\",\n            show: isArray\n                ? !musicItems.every(\n                    (item) => isLocalMusic(item) || Downloader.isDownloaded(item),\n                )\n                : !isLocalMusic(musicItems) && !Downloader.isDownloaded(musicItems),\n            onClick() {\n                Downloader.startDownload(musicItems);\n            },\n        },\n        {\n            title: i18n.t(\"music_list_context_menu.delete_local_download\"),\n            icon: \"trash\",\n            show:\n                (isArray && musicItems.every((it) => Downloader.isDownloaded(it))) ||\n                (!isArray && Downloader.isDownloaded(musicItems)),\n            async onClick() {\n                const [isSuccess, info] = await Downloader.removeDownloadedMusic(\n                    musicItems,\n                    true,\n                );\n                if (isSuccess) {\n                    if (isArray) {\n                        toast.success(\n                            i18n.t(\n                                \"music_list_context_menu.delete_local_downloaded_songs_success\",\n                                {\n                                    musicNums: musicItems.length,\n                                },\n                            ),\n                        );\n                    } else {\n                        toast.success(\n                            i18n.t(\n                                \"music_list_context_menu.delete_local_downloaded_song_success\",\n                                {\n                                    songName: (musicItems as IMusic.IMusicItem).title,\n                                },\n                            ),\n                        );\n                    }\n                } else if (info?.msg) {\n                    toast.error(info.msg);\n                }\n            },\n        },\n        {\n            title: i18n.t(\n                \"music_list_context_menu.reveal_local_music_in_file_explorer\",\n            ),\n            icon: \"folder-open\",\n            show:\n                !isArray &&\n                (Downloader.isDownloaded(musicItems) ||\n                    musicItems?.platform === localPluginName),\n            async onClick() {\n                try {\n                    if (!isArray) {\n                        let realTimeMusicItem = musicItems;\n                        if (musicItems.platform !== localPluginName) {\n                            realTimeMusicItem = await musicSheetDB.musicStore.get([\n                                musicItems.platform,\n                                musicItems.id,\n                            ]);\n                        }\n\n                        const downloadPath = getInternalData<IMusic.IMusicItemInternalData>(\n                            realTimeMusicItem,\n                            \"downloadData\",\n                        )?.path;\n\n                        const result = await shellUtil.showItemInFolder(downloadPath);\n                        if (!result) {\n                            throw new Error();\n                        }\n                    }\n                } catch (e) {\n                    toast.error(\n                        `${i18n.t(\n                            \"music_list_context_menu.reveal_local_music_in_file_explorer_fail\",\n                        )} ${e?.message ?? \"\"}`,\n                    );\n                }\n            },\n        },\n    );\n\n    showContextMenu({\n        x,\n        y,\n        menuItems,\n    });\n}\n\nfunction _MusicList(props: IMusicListProps) {\n    const {\n        musicList,\n        state = RequestStateCode.FINISHED,\n        onPageChange,\n        musicSheet,\n        virtualProps,\n        // getAllMusicItems,\n        doubleClickBehavior,\n        containerStyle,\n        hideRows,\n        enableDrag,\n        onDragEnd,\n    } = props;\n\n    const [sorting, setSorting] = useState<SortingState>([]);\n\n    const musicListRef = useRef(musicList);\n    const columnShownRef = useRef(\n        AppConfig.getConfig(\"normal.musicListColumnsShown\").reduce(\n            (prev, curr) => ({\n                ...prev,\n                [curr]: false,\n            }),\n            {},\n        ),\n    );\n\n    const table = useReactTable({\n        debugAll: false,\n        data: musicList,\n        columns: columnDef,\n        state: {\n            sorting: sorting,\n            columnVisibility: hideRows\n                ? hideRows.reduce((prev, curr) => ({ ...prev, [curr]: false }), {\n                    ...columnShownRef.current,\n                })\n                : columnShownRef.current,\n        },\n        onSortingChange: setSorting,\n        getCoreRowModel: getCoreRowModel(),\n        getSortedRowModel: getSortedRowModel(),\n    });\n\n    const tableContainerRef = useRef<HTMLDivElement>();\n    const virtualController = useVirtualList({\n        data: table.getRowModel().rows,\n        getScrollElement: virtualProps?.getScrollElement,\n        offsetHeight: () => tableContainerRef.current?.offsetTop ?? 0,\n        estimateItemHeight: estimizeItemHeight,\n        fallbackRenderCount: !(\n            virtualProps?.getScrollElement\n        )\n            ? -1\n            : virtualProps?.fallbackRenderCount ?? 50,\n    });\n\n    const [activeItems, setActiveItems] = useState<Set<number>>(new Set());\n    const lastActiveIndexRef = useRef(0);\n\n    useEffect(() => {\n        setActiveItems(new Set());\n        lastActiveIndexRef.current = 0;\n        musicListRef.current = musicList;\n    }, [musicList]);\n\n    useEffect(() => {\n        const ctrlAHandler = (evt: Event) => {\n            evt.preventDefault();\n            setActiveItems(new Set(Array.from({ length: musicListRef.current.length }, (_, i) => i)));\n        };\n        hotkeys(\"Ctrl+A\", \"music-list\", ctrlAHandler);\n\n        return () => {\n            hotkeys.unbind(\"Ctrl+A\", ctrlAHandler);\n        };\n    }, []);\n\n    const _onDrop = useCallback(\n        (fromIndex: number, toIndex: number) => {\n            if (!onDragEnd || fromIndex === toIndex) {\n                // 没有移动\n                return;\n            }\n            const newData = musicList\n                .slice(0, fromIndex)\n                .concat(musicList.slice(fromIndex + 1));\n            newData.splice(\n                fromIndex > toIndex ? toIndex : toIndex - 1,\n                0,\n                musicList[fromIndex],\n            );\n            onDragEnd?.(newData);\n        },\n        [onDragEnd, musicList],\n    );\n\n    return (\n        <div\n            className=\"music-list-container\"\n            style={containerStyle}\n            ref={tableContainerRef}\n            tabIndex={-1}\n            onFocus={() => {\n                hotkeys.setScope(\"music-list\");\n            }}\n            onBlur={() => {\n                hotkeys.setScope(\"all\");\n            }}\n        >\n            <table\n                style={{\n                    height: virtualController.totalHeight + estimizeItemHeight,\n                    tableLayout: \"fixed\",\n                }}\n            >\n                <thead>\n                    <tr>\n                        {table.getHeaderGroups()[0].headers.map((header) => (\n                            <th\n                                key={header.id}\n                                data-id={header.id}\n                                style={{\n                                //@ts-ignore\n                                    width: header.column.columnDef.fr\n                                        ? //@ts-ignore\n                                        `${header.column.columnDef.fr * 100}%`\n                                        : header.column.columnDef.size,\n                                }}\n                                onClick={header.column.getToggleSortingHandler()}\n                            >\n                                {flexRender(\n                                    header.column.columnDef.header,\n                                    header.getContext(),\n                                )}\n                                <div\n                                    className=\"sort-container\"\n                                    data-sorting={header.column.getIsSorted() !== false}\n                                >\n                                    <SwitchCase.Switch switch={header.column.getIsSorted()}>\n                                        <SwitchCase.Case case={\"asc\"}>\n                                            <SvgAsset iconName=\"sort-asc\"></SvgAsset>\n                                        </SwitchCase.Case>\n                                        <SwitchCase.Case case={\"desc\"}>\n                                            <SvgAsset iconName=\"sort-desc\"></SvgAsset>\n                                        </SwitchCase.Case>\n                                        <SwitchCase.Case case={false}>\n                                            <SvgAsset iconName=\"sort\"></SvgAsset>\n                                        </SwitchCase.Case>\n                                    </SwitchCase.Switch>\n                                </div>\n                                {/* <div\n                  onMouseDown={header.getResizeHandler()}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                  }}\n                  className={classNames({\n                    resizer: true,\n                    \"resizer-resizing\": header.column.getIsResizing(),\n                  })}\n                ></div> */}\n                            </th>\n                        ))}\n                    </tr>\n                </thead>\n                <tbody\n                    style={{\n                        transform: `translateY(${virtualController.startTop}px)`,\n                    }}\n                >\n                    {virtualController.virtualItems.map((virtualItem, index) => {\n                        const row = virtualItem.dataItem;\n\n                        if (!row.original) {\n                            return null;\n                        }\n                        // todo 拆出一个组件\n                        return (\n                            <tr\n                                key={row.id}\n                                data-active={\n                                    activeItems.has(virtualItem.rowIndex)\n                                }\n                                onContextMenu={(e) => {\n                                    if (\n                                        activeItems.size > 1\n                                    ) {\n                                        const selectedItems: IMusic.IMusicItem[] = [];\n                                        const rows = table.getRowModel().rows;\n                                        activeItems.forEach(item => {\n                                            selectedItems.push(rows[item].original);\n                                        });\n\n                                        showMusicContextMenu(\n                                            selectedItems,\n                                            e.clientX,\n                                            e.clientY,\n                                            musicSheet?.platform === localPluginName\n                                                ? musicSheet.id\n                                                : undefined,\n                                        );\n                                    } else {\n                                        lastActiveIndexRef.current = virtualItem.rowIndex;\n                                        setActiveItems(new Set([virtualItem.rowIndex]));\n                                        showMusicContextMenu(\n                                            row.original,\n                                            e.clientX,\n                                            e.clientY,\n                                            musicSheet?.platform === localPluginName\n                                                ? musicSheet.id\n                                                : undefined,\n                                        );\n                                    }\n                                }}\n                                onClick={() => {\n                                // 如果点击的时候按下shift\n                                    if (hotkeys.shift) {\n                                        let start = lastActiveIndexRef.current;\n                                        let end = virtualItem.rowIndex;\n\n                                        if (start >= end) {\n                                            [start, end] = [end, start];\n                                        }\n\n                                        if (end > musicListRef.current.length) {\n                                            end = musicListRef.current.length - 1;\n                                        }\n\n                                        setActiveItems(\n                                            new Set(\n                                                Array.from({ length: end - start + 1 }, (_, i) => start + i),\n                                            ),\n                                        );\n                                    } else if (hotkeys.ctrl) {\n                                        const newSet = new Set(activeItems);\n                                        if (newSet.has(virtualItem.rowIndex)) {\n                                            newSet.delete(virtualItem.rowIndex);\n                                        } else {\n                                            newSet.add(virtualItem.rowIndex);\n                                        }\n                                        setActiveItems(newSet);\n                                    } else {\n                                        setActiveItems(new Set([virtualItem.rowIndex]));\n                                        lastActiveIndexRef.current = virtualItem.rowIndex;\n                                    }\n                                }}\n                                onDoubleClick={() => {\n                                    const config =\n                                    doubleClickBehavior ??\n                                    AppConfig.getConfig(\"playMusic.clickMusicList\");\n                                    if (config === \"replace\") {\n                                        trackPlayer.playMusicWithReplaceQueue(\n                                            table.getRowModel().rows.map((it) => it.original),\n                                            row.original,\n                                        );\n                                    } else {\n                                        trackPlayer.playMusic(row.original);\n                                    }\n                                }}\n                                draggable={enableDrag}\n                                onDragStart={(e) => {\n                                // TODO\n                                // if(activeItems) {\n\n                                    // }\n                                    startDrag(e, virtualItem.rowIndex, \"musiclist\");\n                                }}\n                            >\n                                {row.getVisibleCells().map((cell) => (\n                                    <td\n                                        key={cell.id}\n                                        style={{\n                                        //@ts-ignore\n                                            width: cell.column.columnDef.fr\n                                                ? //@ts-ignore\n                                                `${cell.column.columnDef.fr * 100}%`\n                                                : cell.column.columnDef.size,\n                                        }}\n                                    >\n                                        {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                    </td>\n                                ))}\n                                <IfTruthy condition={enableDrag}>\n                                    <IfTruthy condition={index === 0}>\n                                        <DragReceiver\n                                            position=\"top\"\n                                            rowIndex={virtualItem.rowIndex}\n                                            onDrop={_onDrop}\n                                            tag=\"musiclist\"\n                                            insideTable\n                                        ></DragReceiver>\n                                    </IfTruthy>\n                                    <DragReceiver\n                                        position=\"bottom\"\n                                        rowIndex={virtualItem.rowIndex + 1}\n                                        onDrop={_onDrop}\n                                        tag=\"musiclist\"\n                                        insideTable\n                                    ></DragReceiver>\n                                </IfTruthy>\n                            </tr>\n                        );\n                    })}\n                </tbody>\n                <tfoot\n                    style={{\n                        height:\n                            virtualController.totalHeight -\n                            virtualController.virtualItems.length * estimizeItemHeight,\n                    }}\n                ></tfoot>\n            </table>\n            <Condition\n                condition={musicList.length === 0}\n                falsy={\n                    <BottomLoadingState\n                        state={state}\n                        onLoadMore={onPageChange}\n                    ></BottomLoadingState>\n                }\n            >\n                <Empty></Empty>\n            </Condition>\n        </div>\n    );\n}\n\nexport default memo(\n    _MusicList,\n    (prev, curr) =>\n        prev.state === curr.state &&\n        prev.enableDrag === curr.enableDrag &&\n        prev.musicList === curr.musicList &&\n        prev.onPageChange === curr.onPageChange &&\n        prev.onDragEnd === curr.onDragEnd &&\n        prev.musicSheet &&\n        curr.musicSheet &&\n        isSameMedia(prev.musicSheet, curr.musicSheet),\n);\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeItem/index.scss",
    "content": ".components--albumlike-item-container {\n  $width: 140px;\n  $height: 216px;\n  width: $width;\n  height: $height;\n\n  & .album-img-wrapper {\n    width: $width;\n    height: $width;\n    -webkit-user-drag: none;\n    border-radius: 8px;\n    overflow: hidden;\n    position: relative;\n\n    & .album-play-info {\n        position: absolute;\n        box-sizing: border-box;\n        left: 0;\n        bottom: 0;\n        background-color: rgba($color: #000000, $alpha: 0.1);\n        backdrop-filter: blur(10px);\n        width: 100%;\n        height: 1.8rem;\n        font-size: 0.85rem;\n        display: flex;\n        align-items: center;\n        justify-content: space-between;\n        color: #eee;\n        padding-left: 4px;\n        padding-right: 4px;\n        white-space: nowrap;\n\n        & .play-count {\n            display: flex;\n            align-items: center;\n\n            & svg {\n                margin-right: 2px;\n            }\n        }\n\n    }\n\n    & img {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: $width;\n      height: $width;\n      -webkit-user-drag: none;\n      border-radius: 8px;\n      object-fit: cover;\n      transition: transform ease-out 400ms;\n\n      &:hover {\n        transform: scale(1.1);\n      }\n    }\n  }\n\n  & .media-info {\n    margin-top: 6px;\n    height: $height - $width - 6px;\n    width: 100%;\n\n    & .title {\n      font-size: 1.1rem;\n      overflow: hidden;\n      display: -webkit-box;\n      -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical;\n\n      &:hover {\n        color: var(--primaryColor);\n      }\n    }\n\n    & .author {\n      font-size: 0.9rem;\n      margin-top: 4px;\n      display: flex;\n      align-items: center;\n\n      & span {\n        opacity: 0.8;\n        overflow: hidden;\n        white-space: nowrap;\n        text-overflow: ellipsis;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeItem/index.tsx",
    "content": "import { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport \"./index.scss\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport Condition from \"../Condition\";\nimport dayjs from \"dayjs\";\nimport SvgAsset from \"../SvgAsset\";\nimport { normalizeNumber } from \"@/common/normalize-util\";\nimport { memo } from \"react\";\nimport { isCN } from \"@/shared/i18n/renderer\";\n\ninterface IMusicSheetlikeItemProps {\n    mediaItem: IMusic.IMusicSheetItem;\n    onClick?: (mediaItem: IMusic.IMusicSheetItem) => void;\n}\n\nfunction MusicSheetlikeItem(props: IMusicSheetlikeItemProps) {\n    const { mediaItem, onClick } = props;\n\n    return (\n        <div\n            className=\"components--albumlike-item-container\"\n            role=\"button\"\n            onClick={() => {\n                onClick?.(mediaItem);\n            }}\n        >\n            <div className=\"album-img-wrapper\">\n                <img\n                    src={mediaItem?.artwork || mediaItem?.coverImg || albumImg}\n                    onError={setFallbackAlbum}\n                    loading='lazy'\n                ></img>\n                <Condition\n                    condition={\n                        mediaItem?.playCount || mediaItem?.worksNum || mediaItem?.createAt\n                    }\n                >\n                    <div className=\"album-play-info\">\n                        {mediaItem?.createAt ? (\n                            dayjs(mediaItem.createAt).format(\"YYYY-MM-DD\")\n                        ) : (\n                            <div></div>\n                        )}\n                        <div className=\"play-count\">\n                            <Condition condition={mediaItem?.playCount}>\n                                <SvgAsset iconName={\"headphone\"} size={14}></SvgAsset>\n                                {normalizeNumber(mediaItem?.playCount, !isCN())}\n                            </Condition>\n                        </div>\n                    </div>\n                </Condition>\n            </div>\n            <div className=\"media-info\">\n                <div className=\"title\" title={mediaItem?.title}>\n                    {mediaItem?.title}\n                </div>\n                <div className=\"author\" title={mediaItem?.artist ?? mediaItem?.description}>\n                    <span>{mediaItem?.artist ?? mediaItem?.description ?? \"\"}</span>\n                </div>\n            </div>\n        </div>\n    );\n}\n\nexport default memo(\n    MusicSheetlikeItem,\n    (prev, curr) =>\n        prev.mediaItem === curr.mediaItem && prev.onClick === curr.onClick,\n);\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeList/index.scss",
    "content": ".music-sheet-like-list--container {\n    width: 100%;\n\n    & .music-sheet-like-list--body {\n        display: grid;\n        width: 100%;\n        grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n        gap: 16px;\n    }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeList/index.tsx",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { memo } from \"react\";\nimport \"./index.scss\";\nimport BottomLoadingState from \"@/renderer/components/BottomLoadingState\";\nimport MusicSheetlikeItem from \"@/renderer/components/MusicSheetlikeItem\";\nimport Condition from \"../Condition\";\nimport Empty from \"../Empty\";\n\ninterface IMusicSheetlikeListProps {\n    data: IMusic.IMusicSheetItem[];\n    state: RequestStateCode;\n    onLoadMore?: () => void;\n    onClick?: (mediaItem: IMusic.IMusicSheetItem) => void;\n}\n\nfunction MusicSheetlikeList(props: IMusicSheetlikeListProps) {\n    const { data = [], state, onLoadMore, onClick } = props;\n\n    return (\n        <div className=\"music-sheet-like-list--container\">\n            <Condition condition={data.length !== 0} falsy={<Empty></Empty>}>\n                <div className=\"music-sheet-like-list--body\">\n                    {data.map((mediaItem, index) => {\n                        return (\n                            <MusicSheetlikeItem\n                                onClick={() => {\n                                    onClick?.(mediaItem);\n                                }}\n                                mediaItem={mediaItem}\n                                key={index}\n                            ></MusicSheetlikeItem>\n                        );\n                    })}\n                </div>\n            </Condition>\n            <Condition condition={data.length !== 0}>\n                <BottomLoadingState\n                    state={state}\n                    onLoadMore={onLoadMore}\n                ></BottomLoadingState>\n            </Condition>\n        </div>\n    );\n}\n\nexport default memo(\n    MusicSheetlikeList,\n    (prev, curr) => prev.data === curr.data && prev.state === curr.state,\n);\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/components/Body/index.scss",
    "content": ".music-sheetlike-view--body-container {\n  & .operations {\n    margin-top: 1rem;\n    margin-bottom: 1rem;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n\n    & .buttons {\n      display: flex;\n      column-gap: 1rem;\n\n      & .option-button {\n        gap: 4px;\n\n        & svg {\n          width: 1.5em;\n          height: 1.5em;\n        }\n      }\n    }\n\n    & .search-in-music-list-container {\n      width: 280px;\n      position: relative;\n      display: flex;\n      align-items: center;\n      & .search-in-music-list {\n        width: 100%;\n        padding: 0.6rem calc(1.4rem + 12px) 0.6rem 0.8rem;\n        border-radius: 4px;\n      }\n\n      & svg {\n        opacity: 0.6;\n        position: absolute;\n        right: 6px;\n        width: 1.4rem;\n        height: 1.4rem;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/components/Body/index.tsx",
    "content": "import MusicList from \"@/renderer/components/MusicList\";\nimport \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { ReactNode, useEffect, useState, useTransition } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport Loading from \"@/renderer/components/Loading\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { showModal } from \"@/renderer/components/Modal\";\nimport { RequestStateCode, localPluginName } from \"@/common/constant\";\nimport { offsetHeightStore } from \"../../store\";\nimport MusicSheet from \"@/renderer/core/music-sheet\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IProps {\n    musicSheet: IMusic.IMusicSheetItem;\n    musicList: IMusic.IMusicItem[];\n    state?: RequestStateCode;\n    onLoadMore?: () => void;\n    options?: ReactNode;\n}\nexport default function Body(props: IProps) {\n    const { musicList = [], musicSheet, state, onLoadMore, options } = props;\n\n    const [inputSearch, setInputSearch] = useState(\"\");\n    const [filterMusicList, setFilterMusicList] = useState<\n    IMusic.IMusicItem[] | null\n    >(null);\n    const [isPending, startTransition] = useTransition();\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        if (inputSearch.trim() === \"\") {\n            setFilterMusicList(null);\n        } else {\n            startTransition(() => {\n                const caseSensitive = AppConfig.getConfig(\n                    \"playMusic.caseSensitiveInSearch\",\n                );\n                if (caseSensitive) {\n                    setFilterMusicList(\n                        musicList.filter(\n                            (item) =>\n                                item.title?.includes(inputSearch) ||\n                item.artist?.includes(inputSearch) ||\n                item.album?.includes(inputSearch),\n                        ),\n                    );\n                } else {\n                    const searchText = inputSearch.toLocaleLowerCase();\n                    setFilterMusicList(\n                        musicList.filter(\n                            (item) =>\n                                item.title?.toLocaleLowerCase()?.includes(searchText) ||\n                item.artist?.toLocaleLowerCase()?.includes(searchText) ||\n                item.album?.toLocaleLowerCase()?.includes(searchText),\n                        ),\n                    );\n                }\n            });\n        }\n    }, [inputSearch]);\n\n    useEffect(() => {\n        setInputSearch(\"\");\n    }, [musicSheet?.id]);\n\n    return (\n        <div className=\"music-sheetlike-view--body-container\">\n            <div className=\"operations\">\n                <div className=\"buttons\">\n                    <div\n                        role=\"button\"\n                        className=\"option-button\"\n                        data-disabled={!musicList?.length}\n                        data-type=\"primaryButton\"\n                        title={t(\"music_sheet_like_view.play_all\")}\n                        onClick={() => {\n                            if (musicList.length) {\n                                trackPlayer.playMusicWithReplaceQueue(musicList);\n                            }\n                        }}\n                    >\n                        <SvgAsset iconName=\"play\"></SvgAsset>\n                        <span>{t(\"music_sheet_like_view.play_all\")}</span>\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        data-disabled={!musicList?.length}\n                        className=\"add-to-sheet option-button\"\n                        title={t(\"music_sheet_like_view.add_to_sheet\")}\n                        onClick={() => {\n                            showModal(\"AddMusicToSheet\", {\n                                musicItems: musicList,\n                            });\n                        }}\n                    >\n                        <SvgAsset iconName=\"plus\"></SvgAsset>\n                        <span>{t(\"music_sheet_like_view.add_to_sheet\")}</span>\n                    </div>\n                    {options}\n                </div>\n                <div className=\"search-in-music-list-container\">\n                    <input\n                        spellCheck={false}\n                        onChange={(evt) => {\n                            setInputSearch(evt.target.value);\n                        }}\n                        value={inputSearch}\n                        className=\"search-in-music-list\"\n                    ></input>\n                    <SvgAsset iconName=\"magnifying-glass\"></SvgAsset>\n                </div>\n            </div>\n            <Condition\n                condition={\n                    (!isPending || filterMusicList === null) &&\n          state !== RequestStateCode.PENDING_FIRST_PAGE\n                }\n                falsy={<Loading></Loading>}\n            >\n                <MusicList\n                    musicList={filterMusicList ?? musicList}\n                    // getAllMusicItems={() => musicList} // TODO: 过滤歌曲\n                    musicSheet={musicSheet}\n                    state={state}\n                    onPageChange={onLoadMore}\n                    virtualProps={{\n                        getScrollElement() {\n                            return document.querySelector(\"#page-container\");\n                        },\n                        offsetHeight: () => offsetHeightStore.getValue(),\n                    }}\n                    enableDrag={musicSheet?.platform === localPluginName}\n                    onDragEnd={(newData) => {\n                        if (musicSheet?.platform === localPluginName && musicSheet?.id) {\n                            MusicSheet.frontend.updateSheetMusicOrder(musicSheet.id, newData);\n                        }\n                    }}\n                ></MusicList>\n            </Condition>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/components/Header/index.scss",
    "content": ".music-sheetlike-view--header-container {\n  margin-top: 24px;\n  display: flex;\n  min-height: 160px;\n\n  & img {\n    width: 160px;\n    height: 160px;\n    border-radius: 8px;\n    user-select: none;\n    -webkit-user-drag: none;\n    object-fit: cover;\n  }\n\n\n  & .sheet-info-container {\n    margin-left: 1.5rem;\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    row-gap: 1rem;\n\n    & .title-container{\n      display: flex;\n      align-items: center;\n      -webkit-user-drag: none;\n      user-select: text;\n\n      & .title {\n        flex: 1;\n        font-size: 1.8rem;\n        font-weight: 600;\n        overflow: hidden;\n        display: -webkit-box;\n        -webkit-line-clamp: 1;\n        -webkit-box-orient: vertical;\n\n        // 左边有tag的情况\n        &:not(:first-child) {\n          margin-left: 0.5rem;\n        }\n      }\n    }\n\n    & .description-container {\n      font-size: 1rem;\n      line-height: 2;\n\n      &[data-fold=\"true\"] {\n        display: -webkit-box;\n        -webkit-line-clamp: 2;\n        -webkit-box-orient: vertical;\n        overflow: hidden;\n      }\n    }\n\n    & .info-container {\n      font-size: 1rem;\n      opacity: .8;\n      line-height: 2;\n\n      & span:not(:first-child)::before {\n        content: \" · \";\n      }\n    }\n\n\n  }\n\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/components/Header/index.tsx",
    "content": "import { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport \"./index.scss\";\nimport Tag from \"@/renderer/components/Tag\";\nimport Condition, { IfTruthy } from \"@/renderer/components/Condition\";\nimport { useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport dayjs from \"dayjs\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport SvgAsset from \"@renderer/components/SvgAsset\";\nimport { showModal } from \"@renderer/components/Modal\";\n\ninterface IProps {\n    musicSheet: IMusic.IMusicSheetItem;\n    musicList: IMusic.IMusicItem[];\n    hidePlatform?: boolean;\n}\n\nexport default function Header(props: IProps) {\n    const { musicSheet, musicList, hidePlatform } = props;\n    const containerRef = useRef<HTMLDivElement>();\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"music-sheetlike-view--header-container\" ref={containerRef}>\n            <img\n                draggable={false}\n                src={musicSheet?.artwork ?? musicSheet?.coverImg ?? albumImg}\n                onError={setFallbackAlbum}\n                alt={musicSheet?.title}></img>\n            <div className=\"sheet-info-container\">\n                <div className=\"title-container\">\n                    {(musicSheet?.platform && !hidePlatform) ? (\n                        <Tag>{musicSheet?.platform}</Tag>\n                    ) : null}\n\n                    <div className=\"title\">\n                        {musicSheet?.title ?? t(\"media.unknown_title\")}\n                    </div>\n\n                </div>\n\n                <Condition condition={musicSheet?.description}>\n                    <div\n                        className=\"info-container description-container\"\n                        data-fold=\"true\"\n                        title={musicSheet?.description}\n                        onClick={(e) => {\n                            const dataset = e.currentTarget.dataset;\n                            dataset.fold = dataset.fold === \"true\" ? \"false\" : \"true\";\n                        }}\n                    >\n                        {t(\"media.media_description\")}： {musicSheet?.description}\n                    </div>\n                </Condition>\n\n                <Condition condition={musicSheet?.createAt || musicSheet?.playCount}>\n                    <div className=\"info-container\">\n                        <IfTruthy condition={musicSheet?.playCount}>\n                            <span>{t(\"media.media_play_count\")} {musicSheet?.playCount}</span>\n                        </IfTruthy>\n\n                        <IfTruthy condition={musicSheet?.createAt}>\n                            <span>{t(\"media.media_create_at\")} {dayjs(musicSheet?.createAt).format(\"YYYY-MM-DD\")}</span>\n                        </IfTruthy>\n                    </div>\n                </Condition>\n\n                <Condition condition={musicSheet?.artist}>\n                    <div className=\"info-container\">\n                        <span>{t(\"media.media_type_artist\")} {musicSheet?.artist}</span>\n                    </div>\n                </Condition>\n            </div>\n\n\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/index.scss",
    "content": ".music-sheetlike-view--container {\n    width: 100%;\n}"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/index.tsx",
    "content": "import { ReactNode, useEffect } from \"react\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Body from \"./components/Body\";\nimport Header from \"./components/Header\";\nimport { initValue, offsetHeightStore } from \"./store\";\nimport \"./index.scss\";\n\ninterface IMusicSheetlikeViewProps {\n    scrollElement?: HTMLElement;\n    musicSheet: IMusic.IMusicSheetItem;\n    musicList?: IMusic.IMusicItem[];\n    state?: RequestStateCode;\n    onLoadMore?: () => void;\n    options?: ReactNode;\n    /** 是否展示来源tag */\n    hidePlatform?: boolean;\n}\n\nexport default function MusicSheetlikeView(props: IMusicSheetlikeViewProps) {\n    const {\n        musicSheet,\n        musicList,\n        state = RequestStateCode.IDLE,\n        onLoadMore,\n        options,\n        hidePlatform,\n    } = props;\n\n    useEffect(() => {\n        return () => {\n            offsetHeightStore.setValue(initValue);\n        };\n    }, []);\n\n    return (\n        <div className=\"music-sheetlike-view--container\">\n            <Header\n                hidePlatform={hidePlatform}\n                musicSheet={musicSheet}\n                musicList={musicList ?? []}\n            ></Header>\n            <Body\n                musicList={musicList ?? []}\n                musicSheet={musicSheet}\n                state={state}\n                onLoadMore={onLoadMore}\n                options={options}\n            ></Body>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/MusicSheetlikeView/store.ts",
    "content": "import { rem } from \"@/common/constant\";\nimport Store from \"@/common/store\";\n\nexport const initValue = 184 + 4 * rem;\nexport const offsetHeightStore = new Store(initValue);"
  },
  {
    "path": "src/renderer/components/NoPlugin/index.scss",
    "content": ".no-plugin-container {\n    width: 100%;\n    flex: 1;\n    min-height: 300px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    line-height: 2;\n}"
  },
  {
    "path": "src/renderer/components/NoPlugin/index.tsx",
    "content": "import { Link } from \"react-router-dom\";\nimport \"./index.scss\";\nimport { Trans, useTranslation } from \"react-i18next\";\n\ninterface INoPluginProps {\n    supportMethod?: string;\n    height?: number | string;\n}\n\nexport default function NoPlugin(props: INoPluginProps) {\n    const { supportMethod, height } = props ?? {};\n\n    const { t } = useTranslation();\n\n    return (\n        <div\n            className=\"no-plugin-container\"\n            style={{\n                height: height,\n            }}\n        >\n            <span>\n                {supportMethod ? (\n                    <Trans\n                        i18nKey={\n                            \"plugin.info_hint_you_have_no_plugin_with_supported_method\"\n                        }\n                        components={{\n                            highlight: <span className=\"highlight\"></span>,\n                        }}\n                        values={{\n                            supportMethod,\n                        }}\n                    ></Trans>\n                ) : (\n                    t(\"plugin.info_hint_you_have_no_plugin\")\n                )}\n            </span>\n            <span>\n                <Trans\n                    i18nKey={\"plugin.info_hint_install_plugin_before_use\"}\n                    components={{\n                        a: <Link to=\"/main/plugin-manager-view\"></Link>,\n                    }}\n                ></Trans>\n            </span>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/index.tsx",
    "content": "import Store from \"@/common/store\";\nimport templates from \"./templates\";\nimport { useMemo } from \"react\";\n\ntype ITemplate = typeof templates;\ntype IPanelType = keyof ITemplate;\n\ninterface IPanelInfo {\n    type: IPanelType | null;\n    payload: any;\n}\n\nconst panelStore = new Store<IPanelInfo>({\n    type: null,\n    payload: null,\n});\n\nexport default function PanelComponent() {\n    const modalState = panelStore.useValue();\n\n    return useMemo(() => {\n        if (modalState.type) {\n            const Component = templates[modalState.type];\n            return <Component {...(modalState.payload ?? {})}></Component>;\n        }\n        return null;\n    }, [modalState]);\n}\n\nexport function showPanel<T extends keyof ITemplate>(\n    type: T,\n    payload?: Parameters<ITemplate[T]>[0],\n) {\n    panelStore.setValue({\n        type,\n        payload,\n    });\n}\n\nexport function hidePanel() {\n    panelStore.setValue({\n        type: null,\n        payload: null,\n    });\n}\n\n\nexport function getCurrentPanel(){\n    return panelStore.getValue();\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/Base/index.scss",
    "content": ".components--panel-base {\n  position: absolute;\n  z-index: 10000;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  background-color: var(--maskColor);\n  cursor: default !important;\n\n  &[data-cover-header=true] {\n    top: calc(-1 * var(--appHeaderHeight));\n  }\n\n  & .components--panel-base-content {\n    border-top-left-radius: 8px;\n    background-color: var(--backgroundColor);\n    overflow-y: auto;\n    width: 40vw;\n    height: 100%;\n    box-shadow: var(--shadow, var(--shadowColor) -2px 0px 2px);\n    display: flex;\n    flex-direction: column;\n\n    & .components--panel-base-header {\n      width: 100%;\n      display: flex;\n      height: 3rem;\n      box-sizing: border-box;\n      padding-left: 1rem;\n      padding-right: 1rem;\n      align-items: center;\n      justify-content: space-between;\n      font-size: 1.2rem;\n      font-weight: 600;\n      user-select: none;\n      /* background-color: rgba($color: #000000, $alpha: 0.1); */\n      border-bottom: 1px solid var(--dividerColor);\n      flex-shrink: 0;\n\n      & .components--panel-base-header-close {\n        $size: 18px;\n        width: $size;\n        height: $size;\n\n        & svg {\n          width: $size;\n          height: $size;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/Base/index.tsx",
    "content": "import { ReactNode, useEffect, useRef } from \"react\";\nimport \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { hidePanel } from \"../..\";\n\ninterface IBaseModalProps {\n    // 默认区域\n    onDefaultClick?: () => void;\n    // 点击默认区域时关闭\n    defaultClose?: boolean;\n    // 模糊\n    withBlur?: boolean;\n    /** mask区域颜色 */\n    maskColor?: string;\n    /** 标题 */\n    title?: ReactNode;\n    width?: string | number;\n    scrollable?: boolean;\n    children: ReactNode;\n    coverHeader?: boolean;\n}\n\nconst baseId = \"components--panel-base-container\";\n\nfunction Base(props: IBaseModalProps) {\n    const {\n        onDefaultClick,\n        defaultClose = true,\n        maskColor,\n        children,\n        withBlur = false,\n        width,\n        scrollable = true,\n        coverHeader = false,\n    } = props;\n\n    const trapCloseRef = useRef(false);\n\n    return (\n        <div\n            id={baseId}\n            className={`components--panel-base animate__animated animate__fadeIn ${\n                withBlur ? \"blur10\" : \"\"\n            }`}\n            data-cover-header={coverHeader}\n            style={{\n                backgroundColor: maskColor,\n            }}\n            role=\"button\"\n            onMouseDown={(e) => {\n                if ((e.target as HTMLElement)?.id === baseId) {\n                    trapCloseRef.current = true;\n                } else {\n                    trapCloseRef.current = false;\n                }\n            }}\n            onMouseUp={(e) => {\n                if ((e.target as HTMLElement)?.id === baseId && trapCloseRef.current) {\n                    if (defaultClose) {\n                        hidePanel();\n                    } else {\n                        onDefaultClick?.();\n                    }\n                }\n            }}\n            onMouseLeave={() => {\n                trapCloseRef.current = false;\n            }}\n            onMouseOut={() => {\n                trapCloseRef.current = false;\n            }}\n        >\n            <div\n                className=\"components--panel-base-content animate__animated animate__slideInRight shadow\"\n                style={{\n                    width: width,\n                    overflowY: scrollable ? \"auto\" : \"initial\",\n                }}\n            >\n                {children}\n            </div>\n        </div>\n    );\n}\n\ninterface IHeaderProps {\n    children: ReactNode;\n    right?: ReactNode;\n}\nfunction Header(props: IHeaderProps) {\n    const { children, right } = props;\n\n    return (\n        <div className=\"components--panel-base-header\">\n            {children}\n            {right ?? (\n                <div\n                    role=\"button\"\n                    className=\"components--panel-base-header-close opacity-button\"\n                    onClick={() => {\n                        hidePanel();\n                    }}\n                >\n                    <SvgAsset iconName=\"x-mark\"></SvgAsset>\n                </div>\n            )}\n        </div>\n    );\n}\n\nBase.Header = Header;\nexport default Base;\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/MusicComment/index.scss",
    "content": ".music-comment-panel--title-container {\n  margin: 24px 16px 8px;\n  font-size: 1.2rem;\n  font-weight: 600;\n}\n\n.music-comment-panel--body-container {\n  margin-bottom: 16px;\n}\n\n.music-comment-panel--comment-item-container {\n  padding: 16px;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n\n  & .comment-title-container {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 1.1em;\n\n    & .avatar {\n      width: 32px;\n      height: 32px;\n      border-radius: 50%;\n    }\n\n    & span {\n      user-select: auto;\n    }\n  }\n\n\n  & .comment-body-container {\n    padding-left: 40px;\n    user-select: text;\n    cursor: text;\n    line-height: 1.5em;\n\n  }\n\n  & .comment-operations-container{\n    display: flex;\n    justify-content: space-between;\n    padding-left: 40px;\n    opacity: 0.7;\n\n    & .thumb-up {\n      display: flex;\n      align-items: center;\n\n      & svg {\n        width: 1.1em;\n        height: 1.1em;\n      }\n      & span {\n        margin-left: 4px;\n      }\n    }\n  }\n\n\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/MusicComment/index.tsx",
    "content": "import Base from \"@renderer/components/Panel/templates/Base\";\nimport \"./index.scss\";\nimport { useTranslation } from \"react-i18next\";\nimport SvgAsset from \"@renderer/components/SvgAsset\";\nimport dayjs from \"dayjs\";\nimport useComment from \"@renderer/components/Panel/templates/MusicComment/useComment\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@renderer/components/Loading\";\nimport BottomLoadingState from \"@renderer/components/BottomLoadingState\";\n\ninterface IProps {\n    coverHeader?: boolean;\n    musicItem?: IMusic.IMusicItem;\n}\n\nexport default function MusicComment(props: IProps) {\n    const { coverHeader, musicItem } = props;\n    const { t } = useTranslation();\n\n    const [comments, reqState, loadMore] = useComment(musicItem);\n\n\n    return <Base\n        coverHeader={coverHeader}\n        width={540}\n    >\n        <div className=\"music-comment-panel--title-container\">\n            {t(\"media.media_type_comment\")}\n        </div>\n        <div className=\"music-comment-panel--body-container\">\n            {(comments.length === 0 && (reqState & RequestStateCode.LOADING)) ? <Loading></Loading> : <>\n                {comments.map(comment => <MusicCommentItem comment={comment}></MusicCommentItem>)}\n                <BottomLoadingState state={reqState} onLoadMore={loadMore}></BottomLoadingState>\n            </>}\n        </div>\n\n    </Base>;\n}\n\n\ninterface IMusicCommentItemProps {\n    comment: IComment.IComment\n}\n\nfunction MusicCommentItem(props: IMusicCommentItemProps) {\n    const { comment } = props;\n\n    return <div className=\"music-comment-panel--comment-item-container\">\n        <div className=\"comment-title-container\">\n            <img className=\"avatar\"\n                src={comment.avatar}></img>\n            <span>{comment.nickName}</span>\n        </div>\n        <div className=\"comment-body-container\">\n            <span>{comment.comment}</span>\n        </div>\n        <div className=\"comment-operations-container\">\n            {comment.createAt ? <span>{dayjs(comment.createAt).format(\"YYYY-MM-DD\")}</span> : null}\n            <div className=\"thumb-up\">\n                <SvgAsset iconName=\"hand-thumb-up\"></SvgAsset>\n                <span>{comment.like ?? \"-\"}</span>\n            </div>\n        </div>\n    </div>;\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/MusicComment/useComment.ts",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function useComment(musicItem: IMusic.IMusicItem) {\n    const [comments, setComments] = useState<IComment.IComment[]>([]);\n    const [requestStateCode, setRequestStateCode] = useState(RequestStateCode.IDLE);\n    const pageRef = useRef(1);\n\n    const loadMore = async () => {\n        try {\n            if (requestStateCode & RequestStateCode.LOADING) {\n                return;\n            }\n            setRequestStateCode(comments.length > 0 ? RequestStateCode.PENDING_REST_PAGE : RequestStateCode.PENDING_FIRST_PAGE);\n            const response = await PluginManager.callPluginDelegateMethod(musicItem, \"getMusicComments\", musicItem, pageRef.current);\n\n            setComments(prev => prev.concat(response.data ?? []));\n            if (response.isEnd === false) {\n                setRequestStateCode(RequestStateCode.PARTLY_DONE);\n                pageRef.current = pageRef.current + 1;\n            } else {\n                setRequestStateCode(RequestStateCode.FINISHED);\n            }\n        } catch {\n            setRequestStateCode(RequestStateCode.ERROR);\n        }\n    };\n\n\n    useEffect(() => {\n        loadMore();\n    }, []);\n\n\n    return [comments, requestStateCode, loadMore] as const;\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/PlayList/index.scss",
    "content": ".playlist--header {\n  box-sizing: border-box;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding-left: 14px;\n  padding-right: 14px;\n  margin-top: 12px;\n\n  & .playlist--title {\n    font-size: 1.2rem;\n    font-weight: 600;\n  }\n}\n\n.playlist--music-list-container {\n  flex: 1;\n  overflow-y: auto;\n  overflow-x: hidden;\n}\n\n.playlist--music-list-scroll {\n  position: relative;\n  width: 100%;\n  outline: none;\n}\n\n.playlist--divider {\n  width: 100%;\n  height: 1px;\n  background-color: var(--dividerColor);\n  margin-top: 12px;\n  margin-bottom: 0;\n}\n\n.play-list--music-item-container {\n  width: 460px;\n  height: 2.6rem;\n  display: flex;\n  align-items: center;\n  padding-left: 14px;\n  padding-right: 14px;\n  box-sizing: border-box;\n\n  &[data-active=\"true\"] {\n    background: var(--listActiveColor);\n  }\n\n  & .playlist--options {\n    display: flex;\n    gap: 4px;\n  }\n\n  & .playlist--title {\n    margin-left: 8px;\n    width: 180px;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    flex-shrink: 0;\n  }\n\n  & .playlist--artist {\n    margin-left: 8px;\n    width: 106px;\n    overflow: hidden;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    flex-shrink: 0;\n  }\n\n  & .playlist--platform {\n    margin-left: 8px;\n    flex-basis: 0;\n    flex-grow: 1;\n    width: 0;\n  }\n\n  & .playlist--remove {\n    flex-shrink: 0;\n    margin-left: 8px;\n    height: 16px;\n    width: 16px;\n  }\n\n  &:nth-child(even) {\n    background-color: rgba($color: #000000, $alpha: 0.05);\n  }\n  &:hover {\n    background: var(--listHoverColor);\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/PlayList/index.tsx",
    "content": "import \"./index.scss\";\nimport { memo, useEffect, useRef, useState } from \"react\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport Condition, { IfTruthy } from \"@/renderer/components/Condition\";\nimport Empty from \"@/renderer/components/Empty\";\nimport { getMediaPrimaryKey, isSameMedia } from \"@/common/media-util\";\nimport MusicFavorite from \"@/renderer/components/MusicFavorite\";\nimport Tag from \"@/renderer/components/Tag\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport useVirtualList from \"@/hooks/useVirtualList\";\nimport { rem } from \"@/common/constant\";\nimport { showMusicContextMenu } from \"@/renderer/components/MusicList\";\nimport MusicDownloaded from \"@/renderer/components/MusicDownloaded\";\nimport Base from \"../Base\";\nimport hotkeys from \"hotkeys-js\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport DragReceiver, { startDrag } from \"@/renderer/components/DragReceiver\";\nimport { useCurrentMusic, useMusicQueue } from \"@renderer/core/track-player/hooks\";\n\nconst estimateItemHeight = 2.6 * rem;\nconst DRAG_TAG = \"Playlist\";\n\ninterface IProps {\n    coverHeader?: boolean;\n}\n\nexport default function PlayList(props: IProps) {\n    const { coverHeader } = props;\n    const musicQueue = useMusicQueue();\n    const currentMusic = useCurrentMusic();\n    const scrollElementRef = useRef<HTMLDivElement>();\n    const [activeItems, setActiveItems] = useState<Set<number>>(new Set());\n    const lastActiveIndexRef = useRef(0);\n\n    const { t } = useTranslation();\n\n    const virtualController = useVirtualList({\n        estimateItemHeight: estimateItemHeight,\n        data: musicQueue,\n        getScrollElement() {\n            return scrollElementRef.current;\n        },\n        fallbackRenderCount: 0,\n    });\n\n    useEffect(() => {\n        virtualController.setScrollElement(scrollElementRef.current);\n        const currentMusic = trackPlayer.currentMusic;\n        if (currentMusic) {\n            const queue = trackPlayer.musicQueue;\n            const index = queue.findIndex((it) => isSameMedia(it, currentMusic));\n            if (index > 4) {\n                virtualController.scrollToIndex(index - 4);\n            }\n        }\n\n        const ctrlAHandler = (evt: Event) => {\n            evt.preventDefault();\n            const queue = trackPlayer.musicQueue;\n            setActiveItems(new Set(Array.from({ length: queue.length }, (_, i) => i)));\n        };\n        hotkeys(\"Ctrl+A\", \"play-list\", ctrlAHandler);\n\n        return () => {\n            hotkeys.unbind(\"Ctrl+A\", ctrlAHandler);\n        };\n    }, []);\n\n    const onDrop = (fromIndex: number, toIndex: number) => {\n        if (fromIndex === toIndex) {\n            // 没有移动\n            return;\n        }\n        const newData = musicQueue\n            .slice(0, fromIndex)\n            .concat(musicQueue.slice(fromIndex + 1));\n        newData.splice(\n            fromIndex > toIndex ? toIndex : toIndex - 1,\n            0,\n            musicQueue[fromIndex],\n        );\n        trackPlayer.setMusicQueue(newData);\n    };\n\n    useEffect(() => {\n        setActiveItems(new Set());\n    }, [musicQueue]);\n\n    return (\n        <Base width={\"460px\"} scrollable={false} coverHeader={coverHeader}>\n            <div className=\"playlist--header\">\n                <div className=\"playlist--title\">\n                    <Trans\n                        i18nKey={\"panel.play_list_song_num\"}\n                        values={{\n                            number: musicQueue.length,\n                        }}\n                    ></Trans>\n                </div>\n                <div\n                    role=\"button\"\n                    data-type='normalButton'\n                    onClick={() => {\n                        trackPlayer.reset();\n                    }}\n                >\n                    {t(\"common.clear\")}\n                </div>\n            </div>\n            <div className=\"playlist--divider\"></div>\n            <div className=\"playlist--music-list-container\" ref={scrollElementRef}>\n                <Condition condition={musicQueue.length !== 0} falsy={<Empty></Empty>}>\n                    <div\n                        className=\"playlist--music-list-scroll\"\n                        style={{\n                            height: virtualController.totalHeight,\n                        }}\n                        tabIndex={-1}\n                        onFocus={() => {\n                            hotkeys.setScope(\"play-list\");\n                        }}\n                        onBlur={() => {\n                            hotkeys.setScope(\"all\");\n                        }}\n                    >\n                        {virtualController.virtualItems.map((virtualItem) => {\n                            const musicItem = virtualItem.dataItem;\n                            const rowIndex = virtualItem.rowIndex;\n                            return (\n                                <div\n                                    key={virtualItem.rowIndex}\n                                    style={{\n                                        position: \"absolute\",\n                                        left: 0,\n                                        top: virtualItem.top,\n                                    }}\n                                    draggable\n                                    onDragStart={(e) => {\n                                        startDrag(e, rowIndex, DRAG_TAG);\n                                    }}\n                                    onDoubleClick={() => {\n                                        trackPlayer.playMusic(musicItem);\n                                    }}\n                                    onContextMenu={(e) => {\n                                        if (\n                                            activeItems.size > 1\n                                        ) {\n                                            const selectedItems: IMusic.IMusicItem[] = [];\n\n                                            activeItems.forEach(item => {\n                                                selectedItems.push(musicQueue[item]);\n                                            });\n\n                                            showMusicContextMenu(\n                                                selectedItems,\n                                                e.clientX,\n                                                e.clientY,\n                                                \"play-list\",\n                                            );\n                                        } else {\n                                            lastActiveIndexRef.current = virtualItem.rowIndex;\n                                            setActiveItems(new Set([virtualItem.rowIndex]));\n                                            showMusicContextMenu(\n                                                musicItem,\n                                                e.clientX,\n                                                e.clientY,\n                                                \"play-list\",\n                                            );\n                                        }\n                                    }}\n                                    onClick={() => {\n                                        // 如果点击的时候按下shift\n                                        if (hotkeys.shift) {\n                                            let start = lastActiveIndexRef.current;\n                                            let end = virtualItem.rowIndex;\n\n                                            if (start >= end) {\n                                                [start, end] = [end, start];\n                                            }\n\n                                            if (end > musicQueue.length) {\n                                                end = musicQueue.length - 1;\n                                            }\n                                            setActiveItems(\n                                                new Set(\n                                                    Array.from({ length: end - start + 1 }, (_, i) => start + i),\n                                                ),\n                                            );\n                                        } else if (hotkeys.ctrl) {\n                                            const newSet = new Set(activeItems);\n\n                                            if (newSet.has(virtualItem.rowIndex)) {\n                                                newSet.delete(virtualItem.rowIndex);\n                                            } else {\n                                                newSet.add(virtualItem.rowIndex);\n                                            }\n                                            setActiveItems(newSet);\n                                        } else {\n                                            setActiveItems(new Set([virtualItem.rowIndex]));\n                                            lastActiveIndexRef.current = virtualItem.rowIndex;\n                                        }\n                                    }}\n                                >\n                                    <PlayListMusicItem\n                                        key={getMediaPrimaryKey(musicItem)}\n                                        isPlaying={isSameMedia(currentMusic, musicItem)}\n                                        isActive={\n                                            activeItems.has(virtualItem.rowIndex)\n                                        }\n                                        musicItem={musicItem}\n                                    ></PlayListMusicItem>\n\n                                    <IfTruthy condition={rowIndex === 0}>\n                                        <DragReceiver\n                                            position=\"top\"\n                                            rowIndex={0}\n                                            tag={DRAG_TAG}\n                                            insideTable\n                                            onDrop={onDrop}\n                                        ></DragReceiver>\n                                    </IfTruthy>\n                                    <DragReceiver\n                                        position=\"bottom\"\n                                        rowIndex={rowIndex + 1}\n                                        tag={DRAG_TAG}\n                                        onDrop={onDrop}\n                                    ></DragReceiver>\n                                </div>\n                            );\n                        })}\n                    </div>\n                </Condition>\n            </div>\n        </Base>\n    );\n}\n\ninterface IPlayListMusicItemProps {\n    isPlaying: boolean;\n    musicItem: IMusic.IMusicItem;\n    isActive?: boolean;\n}\n\nfunction _PlayListMusicItem(props: IPlayListMusicItemProps) {\n    const { isPlaying, musicItem, isActive } = props;\n\n    if (!musicItem) {\n        return null;\n    }\n\n    return (\n        <div\n            className=\"play-list--music-item-container\"\n            style={{\n                color: `var(--${isPlaying ? \"primaryColor\" : \"textColor\"})`,\n            }}\n            data-active={isActive}\n        >\n            <div className=\"playlist--options\">\n                <MusicFavorite musicItem={musicItem} size={16}></MusicFavorite>\n                <MusicDownloaded musicItem={musicItem} size={16}></MusicDownloaded>\n            </div>\n            <div className=\"playlist--title\" title={musicItem?.title}>\n                {musicItem?.title ?? \"-\"}\n            </div>\n            <div className=\"playlist--artist\" title={musicItem?.artist}>\n                {musicItem?.artist ?? \"-\"}\n            </div>\n            <div className=\"playlist--platform\">\n                <Tag\n                    style={{\n                        width: \"initial\",\n                    }}\n                >\n                    {musicItem?.platform}\n                </Tag>\n            </div>\n            <div\n                className=\"playlist--remove\"\n                role=\"button\"\n                onClick={() => {\n                    trackPlayer.removeMusic(musicItem);\n                }}\n            >\n                <SvgAsset iconName=\"x-mark\" size={16}></SvgAsset>\n            </div>\n        </div>\n    );\n}\n\nconst PlayListMusicItem = memo(\n    _PlayListMusicItem,\n    (prev, curr) =>\n        prev.isPlaying === curr.isPlaying &&\n        prev.musicItem === curr.musicItem &&\n        prev.isActive === curr.isActive,\n);\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/UserVariables/index.scss",
    "content": ".panel--user-variables-submit {\n    font-weight: 400;\n    padding: 0.4rem 0.6rem;\n    font-size: 1rem;\n    border-radius: 8px;\n    color: var(--infoColor, #0A95C8);\n    border: 1px solid currentColor;\n}\n\n.panel--user-variables-container {\n    height: 100%;\n    width: 100%;\n    overflow-y: auto;\n    \n\n\n    & .panel--user-variable-item {\n        width: 100%;\n        height: 3rem;\n        display: flex;\n        align-items: center;\n        padding: 0 12px;\n        box-sizing: border-box;\n\n        & span {\n            width: 6rem;\n            margin-right: 12px;\n            overflow: hidden;\n            white-space: nowrap;\n            text-overflow: ellipsis;\n            flex-shrink: 0;\n        }\n\n        & input {\n            flex: 1;\n        }\n    }\n}"
  },
  {
    "path": "src/renderer/components/Panel/templates/UserVariables/index.tsx",
    "content": "import { useRef } from \"react\";\nimport Base from \"../Base\";\nimport \"./index.scss\";\nimport { hidePanel } from \"../..\";\nimport { toast } from \"react-toastify\";\nimport { useTranslation } from \"react-i18next\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface IUserVariablesProps {\n    plugin: IPlugin.IPluginDelegate;\n    variables: IPlugin.IUserVariable[];\n    initValues?: Record<string, string>;\n}\n\nexport default function (props: IUserVariablesProps) {\n    const { variables = [], initValues = {}, plugin } = props;\n\n    const valueRef = useRef<Record<string, string>>({ ...(initValues ?? {}) });\n    const { t } = useTranslation();\n\n    return (\n        <Base>\n            <Base.Header\n                right={\n                    <div\n                        role=\"button\"\n                        className=\"panel--user-variables-submit\"\n                        onClick={() => {\n                            const currentConfig = AppConfig.getConfig(\"private.pluginMeta\") || {};\n                            const currentPluginConfig = currentConfig?.[plugin.platform] ?? {};\n                            currentPluginConfig.userVariables = valueRef.current;\n                            currentConfig[plugin.platform] = currentPluginConfig;\n                            AppConfig.setConfig({\n                                \"private.pluginMeta\": currentConfig,\n                            });\n\n                            hidePanel();\n                            toast.success(t(\"panel.user_variable_setting_success\"));\n                        }}\n                    >\n                        {t(\"common.confirm\")}\n                    </div>\n                }\n            >\n                {plugin.platform ?? \"\"} {t(\"panel.user_variable\")}\n            </Base.Header>\n            <div className=\"panel--user-variables-container\">\n                {variables.map((variable) => (\n                    <div className=\"panel--user-variable-item\" key={variable.key}>\n                        <span title={variable.name ?? variable.key}>\n                            {variable.name ?? variable.key}\n                        </span>\n                        <input\n                            spellCheck={false}\n                            defaultValue={initValues[variable.key]}\n                            onInput={(e) => {\n                                valueRef.current[variable.key] = (\n                                    e.target as HTMLInputElement\n                                ).value;\n                            }}\n                            placeholder={variable.hint}\n                        ></input>\n                    </div>\n                ))}\n            </div>\n        </Base>\n    );\n}\n"
  },
  {
    "path": "src/renderer/components/Panel/templates/index.ts",
    "content": "import Base from \"./Base\";\nimport PlayList from \"./PlayList\";\nimport UserVariables from \"./UserVariables\";\nimport MusicComment from \"./MusicComment\";\n\nexport default {\n    Base,\n    UserVariables,\n    PlayList,\n    MusicComment,\n};\n"
  },
  {
    "path": "src/renderer/components/SvgAsset/index.tsx",
    "content": "import { memo } from \"react\";\n\nexport type SvgAssetIconNames =\n    | \"album\"\n    | \"array-download-tray\"\n    | \"arrow-left-end-on-rectangle\"\n    | \"cd\"\n    | \"chat-bubble-left-ellipsis\"\n    | \"check\"\n    | \"check-circle\"\n    | \"chevron-double-down\"\n    | \"chevron-double-up\"\n    | \"chevron-down\"\n    | \"chevron-left\"\n    | \"chevron-right\"\n    | \"clock\"\n    | \"code-bracket-square\"\n    | \"cog-8-tooth\"\n    | \"dashboard-speed\"\n    | \"document-plus\"\n    | \"fire\"\n    | \"folder-open\"\n    | \"font-size-larger\"\n    | \"font-size-smaller\"\n    | \"hand-thumb-up\"\n    | \"headphone\"\n    | \"heart-outline\"\n    | \"heart\"\n    | \"identification\"\n    | \"language\"\n    | \"list-bullet\"\n    | \"lock-closed\"\n    | \"lock-open\"\n    | \"logo\"\n    | \"lyric\"\n    | \"lyric-en\"\n    | \"magnifying-glass\"\n    | \"minus\"\n    | \"motion-play\"\n    | \"musical-note\"\n    | \"pause\"\n    | \"pencil-square\"\n    | \"picture-in-picture-line\"\n    | \"play\"\n    | \"playlist\"\n    | \"plus\"\n    | \"plus-circle\"\n    | \"question-mark-circle\"\n    | \"repeat-song-1\"\n    | \"repeat-song\"\n    | \"rolling-1s\"\n    | \"shuffle\"\n    | \"skip-left\"\n    | \"skip-right\"\n    | \"sort\"\n    | \"sort-asc\"\n    | \"sort-desc\"\n    | \"sparkles\"\n    | \"speaker-wave\"\n    | \"speaker-x-mark\"\n    | \"square\"\n    | \"trash\"\n    | \"trophy\"\n    | \"t-shirt-line\"\n    | \"user\"\n    | \"lq\"\n    | \"sd\"\n    | \"hq\"\n    | \"sq\"\n    | \"x-mark\";\n\ninterface IProps {\n    iconName: SvgAssetIconNames;\n    size?: number;\n    title?: string;\n    color?: string;\n}\n/**\n *\n * @param props\n * @returns\n */\nfunction SvgAsset(props: IProps) {\n    // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires\n    const Svg = require(`@/assets/icons/${props.iconName}.svg`);\n\n    return (\n        <Svg.default\n            title={props.title}\n            style={{\n                width: props.size,\n                height: props.size,\n                color: props.color,\n            }}\n        ></Svg.default>\n    );\n}\n\nexport default memo(SvgAsset, (prev, curr) => prev.iconName === curr.iconName);\n"
  },
  {
    "path": "src/renderer/components/SwitchCase/index.tsx",
    "content": "import { ReactElement } from \"react\";\n\ninterface ISwitchProps {\n    switch: any;\n    children: any;\n}\n\nfunction Switch(props: ISwitchProps){\n    const { switch: _switch, children } = props;\n\n    if (Array.isArray(children)) {\n        const validChildren = children.filter(\n            (child) => child.props?.case === _switch,\n        );\n        return validChildren as ReactElement[];\n    }\n    return children.props?.case === _switch ? children : null;\n}\n\ninterface ICaseProps {\n    case: any;\n    children: any;\n}\nfunction Case(props: ICaseProps) {\n    const { children } = props;\n    return children;\n}\n\nconst SwitchCase = {\n    Switch,\n    Case,\n};\n\nexport default SwitchCase;\n"
  },
  {
    "path": "src/renderer/components/Tag/index.scss",
    "content": ".components--tag-container {\n  font-size: 0.9rem;\n  color: var(--primaryColor);\n  border: 1px solid var(--primaryColor);\n  border-radius: 12px;\n  padding: 2px 6px;\n  width: fit-content;\n  max-width: 7rem;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  text-align: center;\n  flex-shrink: 0;\n\n  &[data-fill=true] {\n    background-color: var(--primaryColor);\n    color: white;\n  }\n}\n"
  },
  {
    "path": "src/renderer/components/Tag/index.tsx",
    "content": "import { CSSProperties, ReactNode } from \"react\";\nimport \"./index.scss\";\n\ninterface ITagProps {\n    fill?: boolean;\n    children: ReactNode;\n    style?: CSSProperties\n}\n\nexport default function Tag(props: ITagProps) {\n    return (\n        <div\n            className=\"components--tag-container\"\n            title={typeof props.children === \"string\" ? props.children : undefined}\n            data-fill={props.fill}\n            style={props.style}\n        >\n            {props.children}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/core/backup-resume/index.ts",
    "content": "import MusicSheet from \"../music-sheet\";\n\n/**\n * 恢复\n * @param data 数据\n * @param overwrite 是否覆写歌单\n */\nasync function resume(data: string | Record<string, any>, overwrite?: boolean) {\n    const dataObj = typeof data === \"string\" ? JSON.parse(data) : data;\n\n    const currentSheets = MusicSheet.frontend.getAllSheets();\n    const allSheets: IMusic.IMusicSheetItem[] = dataObj.musicSheets;\n\n    let importedDefaultSheet;\n    for (const sheet of allSheets) {\n        if (overwrite && sheet.id === MusicSheet.defaultSheet.id) {\n            importedDefaultSheet = sheet;\n            continue;\n        }\n        const newSheet = await MusicSheet.frontend.addSheet(sheet.title);\n        await MusicSheet.frontend.addMusicToSheet(sheet.musicList, newSheet.id);\n    }\n    if (overwrite) {\n        for (const sheet of currentSheets) {\n            if (sheet.id === MusicSheet.defaultSheet.id) {\n                if (importedDefaultSheet) {\n                    await MusicSheet.frontend.clearSheet(MusicSheet.defaultSheet.id);\n                    await MusicSheet.frontend.addMusicToFavorite(\n                        importedDefaultSheet.musicList,\n                    );\n                }\n            }\n            await MusicSheet.frontend.removeSheet(sheet.id);\n        }\n    }\n}\n\nconst BackupResume = {\n    resume,\n};\nexport default BackupResume;\n"
  },
  {
    "path": "src/renderer/core/db/music-sheet-db.ts",
    "content": "import { musicRefSymbol } from \"@/common/constant\";\nimport Dexie, { Table } from \"dexie\";\n\nclass MusicSheetDB extends Dexie {\n    // 歌单信息，其中musiclist只存有platform和id\n    sheets: Table<IMusic.IDBMusicSheetItem>;\n    // musicstore 存有歌单内保存所有的音乐信息\n    musicStore: Table<\n    IMusic.IMusicItem & {\n        [musicRefSymbol]: number; // 某个歌曲在歌单中被引用几次，数字\n    }\n    >;\n    localMusicStore: Table<IMusic.IMusicItem & {\n        $$localPath: string; // 本地地址\n    }>;\n\n    constructor() {\n        super(\"musicSheetDB\");\n        this.version(1.1).stores({\n            sheets: \"&id, title, artist, createAt, $$sortIndex\",\n            musicStore: \"[platform+id], title, artist, album\",\n            /** 本地音乐 */\n            localMusicStore: \"[platform+id], title, artist, album, $$localPath\",\n        });\n    }\n}\n\nconst musicSheetDB = new MusicSheetDB();\nexport default musicSheetDB;\n"
  },
  {
    "path": "src/renderer/core/downloader/downloaded-sheet.ts",
    "content": "import {\n    getInternalData,\n    getMediaPrimaryKey,\n    isSameMedia,\n    setInternalData,\n} from \"@/common/media-util\";\nimport Store from \"@/common/store\";\nimport {\n    getUserPreferenceIDB,\n    setUserPreferenceIDB,\n} from \"@/renderer/utils/user-perference\";\nimport musicSheetDB from \"../db/music-sheet-db\";\nimport { internalDataKey, musicRefSymbol } from \"@/common/constant\";\nimport { useEffect, useState } from \"react\";\nimport { DownloadEvts, ee } from \"./ee\";\nimport { fsUtil } from \"@shared/utils/renderer\";\n\nconst downloadedMusicListStore = new Store<IMusic.IMusicItem[]>([]);\nconst downloadedSet = new Set<string>();\n\n// 在初始化歌单时一起初始化\nexport async function setupDownloadedMusicList() {\n    const downloadedPKs = (await getUserPreferenceIDB(\"downloadedList\")) ?? [];\n    downloadedMusicListStore.setValue(await getDownloadedDetails(downloadedPKs));\n    downloadedPKs.forEach((it) => {\n        downloadedSet.add(getMediaPrimaryKey(it));\n    });\n}\n\nasync function getDownloadedDetails(mediaBases: IMedia.IMediaBase[]) {\n    return await musicSheetDB.transaction(\n        \"readonly\",\n        musicSheetDB.musicStore,\n        async () => {\n            const musicDetailList = await musicSheetDB.musicStore.bulkGet(\n                mediaBases.map((item) => [item.platform, item.id]),\n            );\n\n            return musicDetailList;\n        },\n    );\n}\n\nfunction primaryKeyMap(media: IMedia.IMediaBase) {\n    return {\n        platform: media.platform,\n        id: media.id,\n    };\n}\n\n// 添加到已下载完成的列表中\nexport async function addDownloadedMusicToList(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n) {\n    const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n    try {\n        // 筛选出不在列表中的项目\n        const targetMusicList = downloadedMusicListStore.getValue();\n        const validMusicItems = _musicItems.filter(\n            (item) => -1 === targetMusicList.findIndex((mi) => isSameMedia(mi, item)),\n        );\n\n        await musicSheetDB.transaction(\"rw\", musicSheetDB.musicStore, async () => {\n            // 寻找已入库的音乐项目\n            const allMusic = await musicSheetDB.musicStore.bulkGet(\n                validMusicItems.map((item) => [item.platform, item.id]),\n            );\n            allMusic.forEach((mi, index) => {\n                if (mi) {\n                    mi[musicRefSymbol] += 1;\n                    mi[internalDataKey] = {\n                        ...(mi[internalDataKey] ?? {}),\n                        ...(validMusicItems[index][internalDataKey] ?? {}),\n                    };\n                } else {\n                    allMusic[index] = {\n                        ...validMusicItems[index],\n                        [musicRefSymbol]: 1,\n                    };\n                }\n            });\n            await musicSheetDB.musicStore.bulkPut(allMusic);\n            downloadedMusicListStore.setValue((prev) => [...prev, ...allMusic]);\n            allMusic.forEach((it) => {\n                downloadedSet.add(getMediaPrimaryKey(it));\n            });\n            ee.emit(DownloadEvts.Downloaded, allMusic);\n            setUserPreferenceIDB(\n                \"downloadedList\",\n                downloadedMusicListStore.getValue().map(primaryKeyMap),\n            );\n            return true;\n        });\n    } catch {\n        console.log(\"error!!\");\n        return false;\n    }\n}\n\nexport async function removeDownloadedMusic(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    removeFile = false,\n): Promise<ICommon.ICommonReturnType> {\n    const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n\n    let message: string | null = null;\n\n    try {\n        // 1. 获取全部详细信息\n        const toBeRemovedMusicDetail = await musicSheetDB.transaction(\n            \"r\",\n            musicSheetDB.musicStore,\n            async () => {\n                return await musicSheetDB.musicStore.bulkGet(\n                    _musicItems.map((item) => [item.platform, item.id]),\n                );\n            },\n        );\n        // 2. 删除文件，事务中删除会报错\n        let removeResults: boolean[] = [];\n        if (removeFile) {\n            removeResults = await Promise.all(\n                toBeRemovedMusicDetail.map((it) => {\n                    try {\n                        return fsUtil.rimraf(\n                            getInternalData<IMusic.IMusicItemInternalData>(it, \"downloadData\")\n                                ?.path,\n                        );\n                    } catch (e) {\n                        // 删除失败\n                        message = \"部分歌曲删除失败 \" + (e?.message ?? \"\");\n                        return false;\n                    }\n                }),\n            );\n        }\n        // 3. 修改数据库\n        await musicSheetDB.transaction(\"rw\", musicSheetDB.musicStore, async () => {\n            const needDelete: any[] = [];\n            const needUpdate: any[] = [];\n            await Promise.all(\n                toBeRemovedMusicDetail.map(async (musicItem, index) => {\n                    if (!musicItem) {\n                        return;\n                    }\n                    // 1. 如果本地文件删除失败\n                    if (removeFile && !removeResults[index]) {\n                        return;\n                    }\n                    // 只从歌单中删除，引用-1\n                    musicItem[musicRefSymbol]--;\n                    if (musicItem[musicRefSymbol] === 0) {\n                        needDelete.push([musicItem.platform, musicItem.id]);\n                    } else {\n                        // 清空下载\n                        setInternalData<IMusic.IMusicItemInternalData>(\n                            musicItem,\n                            \"downloadData\",\n                            undefined,\n                        );\n                        needUpdate.push(musicItem);\n                    }\n                }),\n            );\n            console.log(needUpdate);\n            await musicSheetDB.musicStore.bulkDelete(needDelete);\n            await musicSheetDB.musicStore.bulkPut(needUpdate);\n\n            downloadedMusicListStore.setValue((prev) =>\n                prev.filter(\n                    (it) => -1 === _musicItems.findIndex((_) => isSameMedia(_, it)),\n                ),\n            );\n            // 触发事件\n            ee.emit(DownloadEvts.RemoveDownload, _musicItems);\n            _musicItems.forEach((it) => {\n                downloadedSet.delete(getMediaPrimaryKey(it));\n            });\n            setUserPreferenceIDB(\n                \"downloadedList\",\n                downloadedMusicListStore.getValue(),\n            );\n        });\n    } catch (e) {\n        message = \"删除失败 \" + (e?.message ?? \"\");\n    }\n    if (message) {\n        return [\n            false,\n            {\n                msg: message,\n            },\n        ];\n    } else {\n        return [true];\n    }\n}\n\nexport function isDownloaded(musicItem: IMedia.IMediaBase) {\n    return musicItem ? downloadedSet.has(getMediaPrimaryKey(musicItem)) : false;\n}\n\nexport const useDownloadedMusicList = downloadedMusicListStore.useValue;\n\nexport function useDownloaded(musicItem: IMedia.IMediaBase) {\n    const [downloaded, setDownloaded] = useState(isDownloaded(musicItem));\n\n    useEffect(() => {\n        const dlCb = (musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) => {\n            if (Array.isArray(musicItems)) {\n                setDownloaded(\n                    (prev) =>\n                        prev ||\n                        musicItems.findIndex((it) => isSameMedia(it, musicItem)) !== -1,\n                );\n            } else {\n                setDownloaded((prev) => prev || isSameMedia(musicItem, musicItems));\n            }\n        };\n\n        const rmCb = (musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) => {\n            if (Array.isArray(musicItems)) {\n                setDownloaded(\n                    (prev) =>\n                        prev &&\n                        musicItems.findIndex((it) => isSameMedia(it, musicItem)) === -1,\n                );\n            } else {\n                setDownloaded((prev) => prev && !isSameMedia(musicItem, musicItems));\n            }\n        };\n\n        if (musicItem) {\n            setDownloaded(isDownloaded(musicItem));\n        }\n\n        ee.on(DownloadEvts.Downloaded, dlCb);\n        ee.on(DownloadEvts.RemoveDownload, rmCb);\n\n        return () => {\n            ee.off(DownloadEvts.Downloaded, dlCb);\n            ee.off(DownloadEvts.RemoveDownload, rmCb);\n        };\n    }, [musicItem]);\n\n    return downloaded;\n}\n"
  },
  {
    "path": "src/renderer/core/downloader/ee.ts",
    "content": "import EventEmitter from \"eventemitter3\";\n\nexport const ee = new EventEmitter();\n\nexport enum DownloadEvts {\n    DownloadStatusUpdated = \"DownloadStatusUpdated\",\n    Downloaded = \"Downloaded\",\n    RemoveDownload = \"RemoveDownload\",\n}\n"
  },
  {
    "path": "src/renderer/core/downloader/index.new.ts",
    "content": "import * as Comlink from \"comlink\";\nimport { getGlobalContext } from \"@shared/global-context/renderer\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport {\n    addDownloadedMusicToList,\n    isDownloaded,\n    setupDownloadedMusicList,\n} from \"@renderer/core/downloader/downloaded-sheet\";\nimport logger from \"@shared/logger/renderer\";\nimport PQueue from \"p-queue\";\nimport EventEmitter from \"eventemitter3\";\nimport { DownloadState, localPluginName } from \"@/common/constant\";\nimport { getQualityOrder, isSameMedia, setInternalData } from \"@/common/media-util\";\nimport { downloadingMusicStore } from \"@renderer/core/downloader/store\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\ntype ProxyMarkedFunction<T> = T &\n    Comlink.ProxyMarked;\n\n\ninterface IDownloadFileOptions {\n    onProgress?: (progress: ICommon.IDownloadFileSize) => void;\n    onEnded?: () => void;\n    onError?: (reason: Error) => void;\n}\n\ninterface IDownloaderWorker {\n    downloadFileNew: (mediaSource: IMusic.IMusicSource,\n        filePath: string, options?: ProxyMarkedFunction<IDownloadFileOptions>) => void\n}\n\n\nexport enum DownloaderEvent {\n    DOWNLOAD_STATE_CHANGED = \"downloader:download-state-changed\",\n    QUEUE_UPDATED = \"queue_updated\",\n}\n\ninterface IDownloaderEvent {\n    [DownloaderEvent.DOWNLOAD_STATE_CHANGED]: (musicItem: IMusic.IMusicItem, status: ITaskStatus) => void;\n}\n\ninterface ITaskStatus {\n    status: DownloadState,\n    progress?: ICommon.IDownloadFileSize,\n    error?: Error\n}\n\nclass Downloader extends EventEmitter<IDownloaderEvent> {\n    private worker: IDownloaderWorker;\n    private static ConcurrencyLimit = 20;\n    private downloadTaskQueue: PQueue;\n    private currentTaskStatus: Map<string, Map<string, ITaskStatus>> = new Map();\n\n    public isReady = false;\n\n    constructor() {\n        super();\n\n        this.on(DownloaderEvent.DOWNLOAD_STATE_CHANGED, (...args) => {\n            console.log(\"DOWNLOAD STATE CHANGE\", ...args);\n            console.log(this.downloadTaskQueue);\n        });\n\n\n    }\n\n    public async setup() {\n        // 1. config\n        const downloadConcurrency = AppConfig.getConfig(\"download.concurrency\");\n\n        // 2. init worker\n        const workerPath = getGlobalContext().workersPath.downloader;\n        if (workerPath) {\n            const worker = new Worker(workerPath);\n            this.worker = Comlink.wrap(worker);\n            this.isReady = true;\n        } else {\n            logger.logInfo(\"Worker path is not defined\");\n        }\n\n        // 3. setup downloading queue\n        this.downloadTaskQueue = new PQueue({\n            concurrency: downloadConcurrency || 5,\n            autoStart: false,\n        });\n        // @ts-ignore\n        window.dd = this.downloadTaskQueue;\n\n        // 4. setup musicsheet\n        setupDownloadedMusicList();\n    }\n\n    public async download(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) {\n        if (!this.worker) {\n            await this.setup();\n        }\n\n        const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n        // 过滤掉已下载的、本地音乐、任务中的音乐\n        const _validMusicItems = _musicItems.filter(\n            (it) => !isDownloaded(it) && it.platform !== localPluginName,\n        );\n\n        const downloadTasks = _validMusicItems.map((it) => {\n\n            this.setTaskStatus(it, {\n                status: DownloadState.WAITING,\n            });\n\n\n            const task = async () => {\n                if (!this.getTaskStatus(it)) {\n                    return;\n                }\n                this.setTaskStatus(it, {\n                    status: DownloadState.DOWNLOADING,\n                    progress: {\n                        currentSize: NaN,\n                        totalSize: NaN,\n                    },\n                });\n\n                const fileName = `${it.title}-${it.artist}`.replace(/[/|\\\\?*\"<>:]/g, \"_\");\n\n                await new Promise<void>((resolve) => {\n                    this.downloadMusicImpl(it, fileName, {\n                        onError: (e) => {\n                            this.setTaskStatus(it, {\n                                status: DownloadState.ERROR,\n                                error: e,\n                            });\n                            resolve();\n                        },\n                        onProgress: (progress) => {\n                            this.setTaskStatus(it, {\n                                status: DownloadState.DOWNLOADING,\n                                progress,\n                            });\n                        },\n                        onEnded: () => {\n                            this.setTaskStatus(it, {\n                                status: DownloadState.DONE,\n                            });\n                            downloadingMusicStore.setValue((prev) =>\n                                prev.filter((di) => !isSameMedia(it, di)),\n                            );\n                            resolve();\n                        },\n                    }).catch((e) => {\n                        this.setTaskStatus(it, {\n                            status: DownloadState.ERROR,\n                            error: e,\n                        });\n                        resolve();\n                    });\n\n                });\n            };\n\n            task.musicItem = it;\n            return task;\n        });\n\n        this.downloadTaskQueue.addAll(downloadTasks);\n        downloadingMusicStore.setValue((prev) => [...prev, ..._validMusicItems]);\n    }\n\n    private async downloadMusicImpl(musicItem: IMusic.IMusicItem, fileName: string, options: IDownloadFileOptions) {\n        // 1. config\n        const [defaultQuality, whenQualityMissing] = [\n            AppConfig.getConfig(\"download.defaultQuality\"),\n            AppConfig.getConfig(\"download.whenQualityMissing\"),\n        ];\n        const downloadBasePath =\n            AppConfig.getConfig(\"download.path\") ??\n            getGlobalContext().appPath.downloads;\n\n        const qualityOrder = getQualityOrder(defaultQuality, whenQualityMissing);\n\n        let mediaSource: IPlugin.IMediaSourceResult | null = null;\n        let realQuality: IMusic.IQualityKey = qualityOrder[0];\n\n\n        for (const quality of qualityOrder) {\n            try {\n                mediaSource = await PluginManager.callPluginDelegateMethod(\n                    musicItem,\n                    \"getMediaSource\",\n                    musicItem,\n                    quality,\n                );\n                if (!mediaSource?.url) {\n                    continue;\n                }\n                realQuality = quality;\n                break;\n            } catch {\n                // pass\n            }\n        }\n\n        if (mediaSource?.url) {\n            const ext = mediaSource.url.match(/.*\\/.+\\.([^./?#]+)/)?.[1] ?? \"mp3\";\n\n            const downloadPath = window.path.resolve(\n                downloadBasePath,\n                `./${fileName}.${ext}`,\n            );\n            this.worker.downloadFileNew(\n                mediaSource,\n                downloadPath,\n                Comlink.proxy({\n                    onError(reason) {\n                        options?.onError(reason);\n                    },\n                    onProgress(progress) {\n                        options?.onProgress?.(progress);\n                    },\n                    onEnded() {\n                        options?.onEnded?.();\n                        addDownloadedMusicToList(\n                            setInternalData<IMusic.IMusicItemInternalData>(\n                                musicItem as any,\n                                \"downloadData\",\n                                {\n                                    path: downloadPath,\n                                    quality: realQuality,\n                                },\n                                true,\n                            ) as IMusic.IMusicItem,\n                        );\n                    },\n                }),\n            );\n        } else {\n            throw new Error(\"Invalid Source\");\n        }\n\n    }\n\n    public setConcurrency(concurrency: number) {\n        if (this.downloadTaskQueue) {\n            this.downloadTaskQueue.concurrency = Math.min(\n                concurrency < 1 ? 1 : concurrency,\n                Downloader.ConcurrencyLimit,\n            );\n        }\n    }\n\n    public getTaskStatus(musicItem: IMusic.IMusicItem): ITaskStatus | null {\n        const platform = \"\" + musicItem.platform;\n        const id = \"\" + musicItem.id;\n\n        return this.currentTaskStatus.get(platform)?.get(id) ?? null;\n    }\n\n    private setTaskStatus(musicItem: IMusic.IMusicItem, taskStatus: ITaskStatus) {\n        const platform = \"\" + musicItem.platform;\n        const id = \"\" + musicItem.id;\n\n        if (!this.currentTaskStatus.has(platform)) {\n            this.currentTaskStatus.set(platform, new Map());\n        }\n\n        if (taskStatus.status === DownloadState.DONE) {\n            this.currentTaskStatus.get(platform)?.delete(id);\n        } else {\n            this.currentTaskStatus.get(platform)?.set(id, taskStatus);\n        }\n        this.emit(DownloaderEvent.DOWNLOAD_STATE_CHANGED, musicItem, taskStatus);\n    }\n}\n\n\nexport default new Downloader();\n"
  },
  {
    "path": "src/renderer/core/downloader/index.ts",
    "content": "import {\n    getMediaPrimaryKey,\n    getQualityOrder,\n    isSameMedia,\n    setInternalData,\n} from \"@/common/media-util\";\nimport * as Comlink from \"comlink\";\nimport { DownloadState, localPluginName } from \"@/common/constant\";\nimport PQueue from \"p-queue\";\nimport {\n    addDownloadedMusicToList,\n    isDownloaded,\n    removeDownloadedMusic,\n    setupDownloadedMusicList,\n    useDownloaded,\n    useDownloadedMusicList,\n} from \"./downloaded-sheet\";\nimport { getGlobalContext } from \"@/shared/global-context/renderer\";\nimport Store from \"@/common/store\";\nimport { useEffect, useState } from \"react\";\nimport { DownloadEvts, ee } from \"./ee\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\n\nexport interface IDownloadStatus {\n    state: DownloadState;\n    downloaded?: number;\n    total?: number;\n    msg?: string;\n}\n\nconst downloadingMusicStore = new Store<Array<IMusic.IMusicItem>>([]);\nconst downloadingProgress = new Map<string, IDownloadStatus>();\n\ntype ProxyMarkedFunction<T extends (...args: any) => void> = T &\n  Comlink.ProxyMarked;\n\ntype IOnStateChangeFunc = (data: IDownloadStatus) => void;\n\ninterface IDownloaderWorker {\n    downloadFile: (\n        mediaSource: IMusic.IMusicSource,\n        filePath: string,\n        onStateChange: ProxyMarkedFunction<IOnStateChangeFunc>\n    ) => Promise<void>;\n}\n\nlet downloaderWorker: IDownloaderWorker;\n\nasync function setupDownloader() {\n    setupDownloaderWorker();\n    setupDownloadedMusicList();\n}\n\nfunction setupDownloaderWorker() {\n    // 初始化worker\n    const downloaderWorkerPath = getGlobalContext().workersPath.downloader;\n    if (downloaderWorkerPath) {\n        const worker = new Worker(downloaderWorkerPath);\n        downloaderWorker = Comlink.wrap(worker);\n    }\n    setDownloadingConcurrency(AppConfig.getConfig(\"download.concurrency\"));\n}\n\nconst concurrencyLimit = 20;\nconst downloadingQueue = new PQueue({\n    concurrency: 5,\n});\n\nfunction setDownloadingConcurrency(concurrency: number) {\n    if (isNaN(concurrency)) {\n        return;\n    }\n    downloadingQueue.concurrency = Math.min(\n        concurrency < 1 ? 1 : concurrency,\n        concurrencyLimit,\n    );\n}\n\nasync function startDownload(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n) {\n    if (!downloaderWorker) {\n        setupDownloaderWorker();\n    }\n\n    const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n    // 过滤掉已下载的、本地音乐、任务中的音乐\n    const _validMusicItems = _musicItems.filter(\n        (it) => !isDownloaded(it) && it.platform !== localPluginName,\n    );\n\n    const downloadCallbacks = _validMusicItems.map((it) => {\n        const pk = getMediaPrimaryKey(it);\n        downloadingProgress.set(pk, {\n            state: DownloadState.WAITING,\n        });\n\n        return async () => {\n            // Not on waiting list\n            if (!downloadingProgress.has(pk)) {\n                return;\n            }\n\n            downloadingProgress.get(pk).state = DownloadState.DOWNLOADING;\n            const fileName = `${it.title}-${it.artist}`.replace(/[/|\\\\?*\"<>:]/g, \"_\");\n            await new Promise<void>((resolve) => {\n                downloadMusicImpl(it, fileName, (stateData) => {\n                    downloadingProgress.set(pk, stateData);\n                    ee.emit(DownloadEvts.DownloadStatusUpdated, it, stateData);\n                    if (stateData.state === DownloadState.DONE) {\n                        downloadingMusicStore.setValue((prev) =>\n                            prev.filter((di) => !isSameMedia(it, di)),\n                        );\n                        downloadingProgress.delete(pk);\n                        resolve();\n                    } else if (stateData.state === DownloadState.ERROR) {\n                        resolve();\n                    }\n                });\n            });\n        };\n    });\n\n    downloadingMusicStore.setValue((prev) => [...prev, ..._validMusicItems]);\n    downloadingQueue.addAll(downloadCallbacks);\n}\n\nasync function downloadMusicImpl(\n    musicItem: IMusic.IMusicItem,\n    fileName: string,\n    onStateChange: IOnStateChangeFunc,\n) {\n    const [defaultQuality, whenQualityMissing] = [\n        AppConfig.getConfig(\"download.defaultQuality\"),\n        AppConfig.getConfig(\"download.whenQualityMissing\"),\n    ];\n    const qualityOrder = getQualityOrder(defaultQuality, whenQualityMissing);\n    let mediaSource: IPlugin.IMediaSourceResult | null = null;\n    let realQuality: IMusic.IQualityKey = qualityOrder[0];\n    for (const quality of qualityOrder) {\n        try {\n            mediaSource = await PluginManager.callPluginDelegateMethod(\n                musicItem,\n                \"getMediaSource\",\n                musicItem,\n                quality,\n            );\n            if (!mediaSource?.url) {\n                continue;\n            }\n            realQuality = quality;\n            break;\n        } catch {}\n    }\n\n    try {\n        if (mediaSource?.url) {\n            const ext = mediaSource.url.match(/.*\\/.+\\.([^./?#]+)/)?.[1] ?? \"mp3\";\n            const downloadBasePath =\n        AppConfig.getConfig(\"download.path\") ??\n        getGlobalContext().appPath.downloads;\n            const downloadPath = window.path.resolve(\n                downloadBasePath,\n                `./${fileName}.${ext}`,\n            );\n            downloaderWorker.downloadFile(\n                mediaSource,\n                downloadPath,\n                Comlink.proxy((dataState) => {\n                    onStateChange(dataState);\n                    if (dataState.state === DownloadState.DONE) {\n                        addDownloadedMusicToList(\n                            setInternalData<IMusic.IMusicItemInternalData>(\n                                musicItem as any,\n                                \"downloadData\",\n                                {\n                                    path: downloadPath,\n                                    quality: realQuality,\n                                },\n                                true,\n                            ) as IMusic.IMusicItem,\n                        );\n                    }\n                }),\n            );\n        } else {\n            throw new Error(\"Invalid Source\");\n        }\n    } catch (e) {\n        console.log(e, \"ERROR\");\n        onStateChange({\n            state: DownloadState.ERROR,\n            msg: e?.message,\n        });\n    }\n}\n\nfunction useDownloadStatus(musicItem: IMusic.IMusicItem) {\n    const [downloadStatus, setDownloadStatus] = useState<IDownloadStatus | null>(\n        null,\n    );\n\n    useEffect(() => {\n        setDownloadStatus(\n            downloadingProgress.get(getMediaPrimaryKey(musicItem)) || null,\n        );\n\n        const updateFn = (mi: IMusic.IMusicItem, stateData: IDownloadStatus) => {\n            if (isSameMedia(mi, musicItem)) {\n                setDownloadStatus(stateData);\n            }\n        };\n\n        ee.on(DownloadEvts.DownloadStatusUpdated, updateFn);\n\n        return () => {\n            ee.off(DownloadEvts.DownloadStatusUpdated, updateFn);\n        };\n    }, [musicItem]);\n\n    return downloadStatus;\n}\n\n// 下载状态\nfunction useDownloadState(musicItem: IMusic.IMusicItem) {\n    const musicStatus = useDownloadStatus(musicItem);\n    const downloaded = useDownloaded(musicItem);\n\n    return (\n        musicStatus?.state || (downloaded ? DownloadState.DONE : DownloadState.NONE)\n    );\n}\n\nconst Downloader = {\n    setupDownloader,\n    startDownload,\n    useDownloadStatus,\n    useDownloadingMusicList: downloadingMusicStore.useValue,\n    useDownloaded,\n    isDownloaded,\n    useDownloadedMusicList,\n    removeDownloadedMusic,\n    setDownloadingConcurrency,\n    useDownloadState,\n};\nexport default Downloader;\n"
  },
  {
    "path": "src/renderer/core/downloader/store.ts",
    "content": "import Store from \"@/common/store\";\n\nconst downloadingMusicStore = new Store<Array<IMusic.IMusicItem>>([]);\nexport { downloadingMusicStore };\n"
  },
  {
    "path": "src/renderer/core/link-lyric/index.ts",
    "content": "import {\n    getInternalData,\n    getMediaPrimaryKey,\n    setInternalData,\n} from \"@/common/media-util\";\nimport { LRUCache } from \"lru-cache\";\nimport musicSheetDB from \"../db/music-sheet-db\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nconst linkLyricCache = new LRUCache({\n    max: 500,\n    allowStale: false,\n});\n\nconst linkLyricKey = \"associatedLrc\";\n\nexport async function linkLyric(\n    from: IMusic.IMusicItem,\n    to: IMusic.IMusicItem,\n) {\n    // 如果歌曲已经入库，更新数据库中的meta信息\n    const filteredMusicItem: IMedia.IUnique = {\n        platform: to.platform,\n        id: to.id,\n    };\n    for (const toPk of PluginManager.getPluginPrimaryKey(to)) {\n        filteredMusicItem[toPk] = to[toPk];\n    }\n    const fromPk = getMediaPrimaryKey(from);\n    linkLyricCache.set(fromPk, filteredMusicItem);\n\n    try {\n        await musicSheetDB.transaction(\"rw\", musicSheetDB.musicStore, async () => {\n            const musicItem = await musicSheetDB.musicStore.get([\n                from.platform,\n                from.id,\n            ]);\n            if (musicItem) {\n                await musicSheetDB.musicStore.put(\n                    setInternalData(musicItem, linkLyricKey, filteredMusicItem, true),\n                );\n            }\n        });\n    } catch (e) {\n        console.log(e);\n    }\n}\n\nexport async function unlinkLyric(musicItem: IMusic.IMusicItem) {\n    const pk = getMediaPrimaryKey(musicItem);\n    const cachedItem = linkLyricCache.get(pk);\n    if (cachedItem) {\n        linkLyricCache.delete(pk);\n    }\n\n    try {\n        await musicSheetDB.transaction(\"rw\", musicSheetDB.musicStore, async () => {\n            const dbMusicItem = await musicSheetDB.musicStore.get([\n                musicItem.platform,\n                musicItem.id,\n            ]);\n            if (dbMusicItem) {\n                await musicSheetDB.musicStore.put(\n                    setInternalData(dbMusicItem, linkLyricKey, undefined, true),\n                );\n            }\n        });\n    } catch {}\n}\n\nexport async function getLinkedLyric(musicItem: IMusic.IMusicItem) {\n    const pk = getMediaPrimaryKey(musicItem);\n\n    const cachedItem = linkLyricCache.get(pk);\n\n    if (cachedItem) {\n        return cachedItem as IMusic.IMusicItem;\n    }\n    try {\n        const result = await musicSheetDB.transaction(\n            \"r\",\n            musicSheetDB.musicStore,\n            async () => {\n                const dbMusicItem = await musicSheetDB.musicStore.get([\n                    musicItem.platform,\n                    musicItem.id,\n                ]);\n                if (dbMusicItem) {\n                    const linkedLyric = getInternalData(dbMusicItem, linkLyricKey);\n                    return linkedLyric;\n                }\n            },\n        );\n        if (result) {\n            linkLyricCache.set(pk, result);\n            return result;\n        }\n    } catch (e) {\n        console.log(e);\n    }\n    return null;\n}\n"
  },
  {
    "path": "src/renderer/core/local-music/index.ts",
    "content": "import localMusicListStore from \"./store\";\nimport { getUserPreferenceIDB } from \"@/renderer/utils/user-perference\";\nimport * as Comlink from \"comlink\";\nimport musicSheetDB from \"../db/music-sheet-db\";\nimport { getGlobalContext } from \"@/shared/global-context/renderer\";\n\ntype ProxyMarkedFunction<T extends (...args: any) => void> = T &\n    Comlink.ProxyMarked;\n\ntype IMusicItemWithLocalPath = IMusic.IMusicItem & { $$localPath: string };\n\ninterface ILocalFileWatcherWorker {\n    setupWatcher: (initPaths?: string[]) => Promise<void>;\n    changeWatchPath: (addPaths?: string[], rmPaths?: string[]) => Promise<void>;\n    onAdd: (\n        cb: ProxyMarkedFunction<\n            (musicItems: Array<IMusicItemWithLocalPath>) => Promise<void>\n        >\n    ) => void;\n    onRemove: (\n        cb: ProxyMarkedFunction<(filePaths: string[]) => Promise<void>>\n    ) => void;\n}\n\nlet localFileWatcherWorker: ILocalFileWatcherWorker;\n\nfunction isSubDir(parent: string, target: string) {\n    const relative = window.path.relative(parent, target);\n    return (\n        relative && !relative.startsWith(\"..\") && !window.path.isAbsolute(relative)\n    );\n}\n\nasync function setupLocalMusic() {\n    try {\n        const localWatchDir =\n            (await getUserPreferenceIDB(\"localWatchDirChecked\")) ?? [];\n\n\n        const localFileWatcherWorkerPath =\n            getGlobalContext().workersPath.localFileWatcher;\n        if (localFileWatcherWorkerPath) {\n            const worker = new Worker(localFileWatcherWorkerPath);\n            localFileWatcherWorker = Comlink.wrap(worker);\n            await localFileWatcherWorker.setupWatcher(localWatchDir);\n        }\n\n        const allMusic = await musicSheetDB.localMusicStore.toArray();\n\n        localMusicListStore.setValue(allMusic);\n        localFileWatcherWorker.onAdd(\n            Comlink.proxy(async (musicItems: IMusicItemWithLocalPath[]) => {\n                await musicSheetDB.transaction(\n                    \"rw\",\n                    musicSheetDB.localMusicStore,\n                    async () => {\n                        await musicSheetDB.localMusicStore.bulkPut(musicItems);\n                        const allMusic = await musicSheetDB.localMusicStore.toArray();\n                        localMusicListStore.setValue(allMusic);\n                    },\n                );\n            }),\n        );\n\n        localFileWatcherWorker.onRemove(\n            Comlink.proxy(async (filePaths: string[]) => {\n                await musicSheetDB.transaction(\n                    \"rw\",\n                    musicSheetDB.localMusicStore,\n                    async () => {\n                        const tobeDeletedFilePaths = new Set(filePaths);\n                        const cachedLocalMusic = localMusicListStore.getValue();\n                        const tobeDeletedPrimaryKeys: any[] = [];\n                        const newCachedLocalMusic: IMusicItemWithLocalPath[] = [];\n                        cachedLocalMusic.forEach((it) => {\n                            if (tobeDeletedFilePaths.has(it.$$localPath)) {\n                                tobeDeletedPrimaryKeys.push([it.platform, it.id]);\n                            } else {\n                                newCachedLocalMusic.push(it);\n                            }\n                        });\n                        await musicSheetDB.localMusicStore.bulkDelete(\n                            tobeDeletedPrimaryKeys,\n                        );\n                        localMusicListStore.setValue(newCachedLocalMusic);\n                    },\n                );\n            }),\n        );\n    } catch {\n    }\n}\n\nasync function changeWatchPath(logs: Map<string, \"add\" | \"delete\">) {\n    // 对所有的要删除的路径\n    const tobeDeletedPaths: string[] = [];\n    const tobeAddedPaths: string[] = [];\n    logs.forEach((action, dirPath) => {\n        if (action === \"delete\") {\n            tobeDeletedPaths.push(dirPath);\n        } else {\n            tobeAddedPaths.push(dirPath);\n        }\n    });\n\n    // 删除所有子路径的\n    if (tobeDeletedPaths.length) {\n        await musicSheetDB.transaction(\n            \"rw\",\n            musicSheetDB.localMusicStore,\n            async () => {\n                const localFiles = localMusicListStore.getValue();\n                const tobeDeletedItems = localFiles\n                    .filter((it) =>\n                        tobeDeletedPaths.some((deletePath) =>\n                            isSubDir(deletePath, it.$$localPath),\n                        ),\n                    )\n                    .map((it) => [it.platform, it.id]);\n                await musicSheetDB.localMusicStore.bulkDelete(tobeDeletedItems);\n            },\n        );\n\n        localMusicListStore.setValue(await musicSheetDB.localMusicStore.toArray());\n    }\n    // 通知\n    localFileWatcherWorker.changeWatchPath(tobeAddedPaths, tobeDeletedPaths);\n}\n\n// async function syncLocalMusic() {\n//   ipcRendererSend(\"sync-local-music\");\n// }\n\nexport default {\n    setupLocalMusic,\n    // syncLocalMusic,\n    changeWatchPath,\n};\n"
  },
  {
    "path": "src/renderer/core/local-music/store.ts",
    "content": "import Store from \"@/common/store\";\n\nconst localMusicListStore = new Store<Array<IMusic.IMusicItem & {\n    $$localPath: string\n}>>([]);\nexport default localMusicListStore;"
  },
  {
    "path": "src/renderer/core/music-sheet/backend/index.ts",
    "content": "/**\n * 这里不应该写任何和UI有关的逻辑，只是简单的数据库操作\n *\n * 除了frontend文件夹外，其他任何地方不应该直接调用此处定义的函数\n */\n\nimport { localPluginName, musicRefSymbol, sortIndexSymbol, timeStampSymbol } from \"@/common/constant\";\nimport { nanoid } from \"nanoid\";\nimport musicSheetDB from \"../../db/music-sheet-db\";\nimport { produce } from \"immer\";\nimport defaultSheet from \"../common/default-sheet\";\nimport { getMediaPrimaryKey, isSameMedia } from \"@/common/media-util\";\nimport { getUserPreferenceIDB, setUserPreferenceIDB } from \"@/renderer/utils/user-perference\";\n\n/******************** 内存缓存 ***********************/\n// 默认歌单，快速判定是否在列表中\nconst favoriteMusicListIds = new Set<string>();\n// 全部的歌单列表(无详情，只有ID)\nlet musicSheets: IMusic.IDBMusicSheetItem[] = [];\n// 星标的歌单信息\nlet starredMusicSheets: IMedia.IMediaBase[] = [];\n\n/******************** 方法 ***********************/\n\n/**\n * 获取全部音乐信息\n * @returns\n */\nexport function getAllSheets() {\n    return musicSheets;\n}\n\nexport function getAllStarredSheets() {\n    return starredMusicSheets;\n}\n\n/**\n *\n * 查询所有歌单信息（无详情）\n *\n * @returns 全部歌单信息\n */\nexport async function queryAllSheets() {\n    try {\n        // 读取全部歌单\n        const allSheets = await musicSheetDB.sheets.toArray();\n\n        const defaultSheetIndex = allSheets.findIndex(item => item.id === defaultSheet.id);\n\n        if (allSheets.length === 0 || defaultSheetIndex === -1) {\n            await musicSheetDB.transaction(\n                \"readwrite\",\n                musicSheetDB.sheets,\n                async () => {\n                    musicSheetDB.sheets.put(defaultSheet);\n                },\n            );\n            musicSheets = [defaultSheet, ...allSheets];\n        } else {\n            const dbDefaultSheet = allSheets.find(\n                (item) => item.id === defaultSheet.id,\n            );\n            dbDefaultSheet.musicList.forEach((mi) => {\n                favoriteMusicListIds.add(getMediaPrimaryKey(mi));\n            });\n            musicSheets = allSheets;\n\n            if (defaultSheetIndex !== 0) {\n                allSheets.splice(defaultSheetIndex, 1);\n                allSheets.unshift(dbDefaultSheet);\n            }\n        }\n\n        // 收藏歌单\n        return musicSheets;\n    } catch (e) {\n        console.log(e);\n        return musicSheets;\n    }\n}\n\n/**\n * 查询所有收藏歌单\n * @returns 收藏歌单信息\n */\nexport async function queryAllStarredSheets() {\n    try {\n        starredMusicSheets =\n            (await getUserPreferenceIDB(\"starredMusicSheets\")) || [];\n        return starredMusicSheets;\n    } catch {\n        return [];\n    }\n}\n\n/**\n * 新建歌单\n * @param sheetName 歌单名\n * @returns 新建的歌单信息\n */\nexport async function addSheet(sheetName: string) {\n    const id = nanoid();\n    const newSheet: IMusic.IMusicSheetItem = {\n        id,\n        title: sheetName,\n        createAt: Date.now(),\n        platform: localPluginName,\n        musicList: [],\n        $$sortIndex: musicSheets[musicSheets.length - 1].$$sortIndex + 1,\n    };\n    try {\n        await musicSheetDB.transaction(\n            \"readwrite\",\n            musicSheetDB.sheets,\n            async () => {\n                musicSheetDB.sheets.put(newSheet);\n            },\n        );\n        musicSheets = [...musicSheets, newSheet];\n        return newSheet;\n    } catch {\n        throw new Error(\"新建失败\");\n    }\n}\n\n/**\n * 更新歌单信息\n * @param sheetId 歌单ID\n * @param newData 最新的歌单信息\n * @returns\n */\nexport async function updateSheet(\n    sheetId: string,\n    newData: Partial<IMusic.IMusicSheetItem>,\n) {\n    try {\n        if (!newData) {\n            return;\n        }\n        await musicSheetDB.transaction(\n            \"readwrite\",\n            musicSheetDB.sheets,\n            async () => {\n                musicSheetDB.sheets.update(sheetId, newData);\n            },\n        );\n\n        musicSheets = produce(musicSheets, (draft) => {\n            const currentIndex = draft.findIndex((_) => _.id === sheetId);\n            if (currentIndex === -1) {\n                draft.push(newData as IMusic.IDBMusicSheetItem);\n            } else {\n                draft[currentIndex] = {\n                    ...draft[currentIndex],\n                    ...newData,\n                };\n            }\n        });\n    } catch (e) {\n        // 更新歌单信息失败\n        console.log(e);\n    }\n}\n\n/**\n * 移除歌单\n * @param sheetId 歌单ID\n * @returns 删除后的ID\n */\nexport async function removeSheet(sheetId: string) {\n    try {\n        if (sheetId === defaultSheet.id) {\n            // 默认歌单不可删除\n            return;\n        }\n        await musicSheetDB.transaction(\n            \"readwrite\",\n            musicSheetDB.sheets,\n            musicSheetDB.musicStore,\n            async () => {\n                const targetSheet = musicSheets.find((item) => item.id === sheetId);\n\n                await removeMusicFromSheet(\n                    targetSheet.musicList ?? ([] as any),\n                    sheetId,\n                );\n                musicSheetDB.sheets.delete(sheetId);\n            },\n        );\n        musicSheets = musicSheets.filter((it) => it.id !== sheetId);\n        return musicSheets;\n    } catch (e) {\n        console.log(e);\n    }\n}\n\n/**\n * 清空所有音乐\n * @param sheetId 歌单ID\n * @returns 删除后的ID\n */\nexport async function clearSheet(sheetId: string) {\n    try {\n        await musicSheetDB.transaction(\n            \"readwrite\",\n            musicSheetDB.sheets,\n            musicSheetDB.musicStore,\n            async () => {\n                const targetSheet = musicSheets.find((item) => item.id === sheetId);\n                await removeMusicFromSheet(\n                    targetSheet.musicList ?? ([] as any),\n                    sheetId,\n                );\n                targetSheet.musicList = [];\n            },\n        );\n        return [...musicSheets];\n    } catch (e) {\n        console.log(e);\n    }\n}\n\n/**\n * 收藏歌单\n * @param sheet\n */\nexport async function starMusicSheet(sheet: IMedia.IMediaBase) {\n    const newSheets = [...starredMusicSheets, sheet];\n    await setUserPreferenceIDB(\"starredMusicSheets\", newSheets);\n    starredMusicSheets = newSheets;\n}\n\n/**\n * 取消收藏歌单\n * @param sheet\n */\nexport async function unstarMusicSheet(sheet: IMedia.IMediaBase) {\n    const newSheets = starredMusicSheets.filter(\n        (item) => !isSameMedia(item, sheet),\n    );\n    await setUserPreferenceIDB(\"starredMusicSheets\", newSheets);\n    starredMusicSheets = newSheets;\n}\n\n/**\n * 收藏歌单排序\n */\n\nexport async function setStarredMusicSheets(sheets: IMedia.IMediaBase[]) {\n    await setUserPreferenceIDB(\"starredMusicSheets\", sheets);\n    starredMusicSheets = sheets;\n}\n\n/**************************** 歌曲相关方法 ************************/\n\n/**\n * 添加歌曲到歌单\n * @param musicItems\n * @param sheetId\n * @returns\n */\nexport async function addMusicToSheet(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    sheetId: string,\n) {\n    const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n    try {\n        // 当前的列表\n        const targetSheet = musicSheets.find((item) => item.id === sheetId);\n        if (!targetSheet) {\n            return;\n        }\n        // 筛选出不在列表中的项目\n        const targetMusicList = targetSheet.musicList;\n        // 要添加到音乐列表中的项目\n        const validMusicItems = _musicItems.filter(\n            (item) => -1 === targetMusicList.findIndex((mi) => isSameMedia(mi, item)),\n        );\n\n        await musicSheetDB.transaction(\n            \"rw\",\n            musicSheetDB.musicStore,\n            musicSheetDB.sheets,\n            async () => {\n                // 寻找已入库的音乐项目\n                const allMusic = await musicSheetDB.musicStore.bulkGet(\n                    validMusicItems.map((item) => [item.platform, item.id]),\n                );\n                allMusic.forEach((mi, index) => {\n                    if (mi) {\n                        mi[musicRefSymbol] += 1;\n                    } else {\n                        allMusic[index] = {\n                            ...validMusicItems[index],\n                            [musicRefSymbol]: 1,\n                        };\n                    }\n                });\n                await musicSheetDB.musicStore.bulkPut(allMusic);\n                const timeStamp = Date.now();\n                await musicSheetDB.sheets\n                    .where(\"id\")\n                    .equals(sheetId)\n                    .modify((obj) => {\n                        obj.artwork =\n                            validMusicItems[validMusicItems.length - 1]?.artwork ??\n                            obj.artwork;\n                        obj.musicList = [\n                            ...(obj.musicList ?? []),\n                            ...validMusicItems.map((item, index) => ({\n                                platform: item.platform,\n                                id: item.id,\n                                [sortIndexSymbol]: index,\n                                [timeStampSymbol]: timeStamp,\n                            })),\n                        ];\n                        targetSheet.artwork = obj.artwork;\n                        targetSheet.musicList = obj.musicList;\n                        musicSheets = [...musicSheets];\n                    });\n            },\n        );\n\n        if (sheetId === defaultSheet.id) {\n            _musicItems.forEach((mi) => {\n                favoriteMusicListIds.add(getMediaPrimaryKey(mi));\n            });\n        }\n\n        return musicSheets;\n    } catch {\n        console.log(\"error!!\");\n    }\n}\n\n/**\n * 从歌单内移除歌曲\n * @param musicItems 要移除的歌曲\n * @param sheetId 歌单ID\n * @returns\n */\nexport async function removeMusicFromSheet(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    sheetId: string,\n) {\n    const targetSheet = musicSheets.find((item) => item.id === sheetId);\n    if (!targetSheet) {\n        return;\n    }\n    // 重新组装\n    const _musicItems = Array.isArray(musicItems) ? musicItems : [musicItems];\n    const targetMusicList = targetSheet.musicList ?? [];\n    const toBeRemovedMusic: IMedia.IMediaBase[] = [];\n    const restMusic: IMedia.IMediaBase[] = [];\n    for (const mi of targetMusicList) {\n        // 用map会更快吧\n        if (_musicItems.findIndex((item) => isSameMedia(mi, item)) === -1) {\n            // 剩余的音乐\n            restMusic.push(mi);\n        } else {\n            // 将要删除的音乐\n            toBeRemovedMusic.push(mi);\n        }\n    }\n\n    try {\n        await musicSheetDB.transaction(\n            \"rw\",\n            musicSheetDB.sheets,\n            musicSheetDB.musicStore,\n            async () => {\n                // 寻找引用\n                const toBeRemovedMusicDetail = await musicSheetDB.musicStore.bulkGet(\n                    toBeRemovedMusic.map((item) => [item.platform, item.id]),\n                );\n                // 如果引用计数为0，进入删除队列\n                const needDelete: any[] = [];\n                // 如果不为0，进入更新队列\n                const needUpdate: any[] = [];\n                toBeRemovedMusicDetail.forEach((musicItem) => {\n                    if (!musicItem) {\n                        return;\n                    }\n                    musicItem[musicRefSymbol]--;\n                    if (musicItem[musicRefSymbol] === 0) {\n                        needDelete.push([musicItem.platform, musicItem.id]);\n                    } else {\n                        needUpdate.push(musicItem);\n                    }\n                });\n                await musicSheetDB.musicStore.bulkDelete(needDelete);\n                await musicSheetDB.musicStore.bulkPut(needUpdate);\n\n                // 当前的最后一首歌\n                const lastMusic = restMusic[restMusic.length - 1];\n                // 更新当前歌单的封面\n                let newArtwork: string;\n                if (lastMusic) {\n                    newArtwork = (\n                        await musicSheetDB.musicStore.get([\n                            lastMusic.platform,\n                            lastMusic.id,\n                        ])\n                    ).artwork;\n                }\n\n                await musicSheetDB.sheets\n                    .where(\"id\")\n                    .equals(sheetId)\n                    .modify((obj) => {\n                        obj.artwork = newArtwork;\n                        obj.musicList = restMusic;\n                        // 修改 MusicSheets\n                        targetSheet.artwork = newArtwork;\n                        targetSheet.musicList = obj.musicList;\n                        musicSheets = [...musicSheets];\n                    });\n            },\n        );\n\n        if (sheetId === defaultSheet.id) {\n            // 从默认歌单里删除\n            toBeRemovedMusic.forEach((mi) => {\n                favoriteMusicListIds.delete(getMediaPrimaryKey(mi));\n            });\n        }\n    } catch (e) {\n        console.log(e);\n        throw e;\n    }\n}\n\n/** 获取歌单内的歌曲详细信息 */\nexport async function getSheetItemDetail(\n    sheetId: string,\n): Promise<IMusic.IMusicSheetItem | null> {\n    // 取太多歌曲时会卡顿， 1000首歌大约100ms\n    const targetSheet = musicSheets.find((item) => item.id === sheetId);\n    if (!targetSheet) {\n        return null;\n    }\n    const tmpResult = [];\n    const musicList = targetSheet.musicList ?? [];\n    // 一组800个\n    const groupSize = 800;\n    const groupNum = Math.ceil(musicList.length / groupSize);\n\n    for (let i = 0; i < groupNum; ++i) {\n        const sliceResult = await musicSheetDB.transaction(\n            \"readonly\",\n            musicSheetDB.musicStore,\n            async () => {\n                return await musicSheetDB.musicStore.bulkGet(\n                    musicList\n                        .slice(i * groupSize, (i + 1) * groupSize)\n                        .map((item) => [item.platform, item.id]),\n                );\n            },\n        );\n\n        tmpResult.push(...(sliceResult ?? []));\n    }\n\n    return {\n        ...targetSheet,\n        musicList: tmpResult,\n    } as IMusic.IMusicSheetItem;\n}\n\n/**\n * 某首歌是否被标记为喜欢\n * @param musicItem\n * @returns\n */\nexport function isFavoriteMusic(musicItem: IMusic.IMusicItem) {\n    return favoriteMusicListIds.has(getMediaPrimaryKey(musicItem));\n}\n\n/** 导出所有歌单信息 */\nexport async function exportAllSheetDetails() {\n    return await musicSheetDB.transaction(\n        \"readonly\",\n        musicSheetDB.musicStore,\n        async () => {\n            const allSheets = musicSheets;\n            if (!allSheets) {\n                return [];\n            }\n            const musicLists = await Promise.all(\n                allSheets.map((sheet) =>\n                    musicSheetDB.musicStore.bulkGet(\n                        (sheet.musicList ?? []).map((item) => [item.platform, item.id]),\n                    ),\n                ),\n            );\n\n            const allSheetDetails = produce(allSheets, (draft) => {\n                draft.forEach((sheet, index) => {\n                    sheet.musicList = musicLists[index];\n                });\n            });\n\n            return allSheetDetails;\n        },\n    );\n}\n"
  },
  {
    "path": "src/renderer/core/music-sheet/common/default-sheet.ts",
    "content": "import { localPluginName } from \"@/common/constant\";\nimport { i18n } from \"@/shared/i18n/renderer\";\n\nexport default {\n    id: \"favorite\",\n    title: i18n.t(\"media.default_favorite_sheet_name\"),\n    platform: localPluginName,\n    musicList: [],\n    $$sortIndex: -1,\n    $sortIndex: -1,\n};\n"
  },
  {
    "path": "src/renderer/core/music-sheet/frontend/index.old.ts",
    "content": "import Store from \"@/common/store\";\nimport * as backend from \"../backend\";\nimport defaultSheet from \"../common/default-sheet\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { RequestStateCode, localPluginName } from \"@/common/constant\";\nimport { toMediaBase } from \"@/common/media-util\";\n\nconst musicSheetsStore = new Store<IMusic.IDBMusicSheetItem[]>([]);\nconst starredSheetsStore = new Store<IMedia.IMediaBase[]>([]);\n\nexport const useAllSheets = musicSheetsStore.useValue;\nexport const useAllStarredSheets = starredSheetsStore.useValue;\n\nexport const getAllSheets = musicSheetsStore.getValue;\n\n/** 更新默认歌单变化 */\nconst refreshFavCbs = new Set<() => void>();\nfunction refreshFavoriteState() {\n    refreshFavCbs.forEach((cb) => cb?.());\n}\n\n/**\n * 初始化\n */\nexport async function setupMusicSheets() {\n    const [musicSheets, starredSheets] = await Promise.all([\n        backend.queryAllSheets(),\n        backend.queryAllStarredSheets(),\n    ]);\n    musicSheetsStore.setValue(musicSheets);\n    starredSheetsStore.setValue(starredSheets);\n}\n\n/**\n * 新建歌单\n * @param sheetName 歌单名\n * @returns 新建的歌单信息\n */\nexport async function addSheet(sheetName: string) {\n    try {\n        const newSheetDetail = await backend.addSheet(sheetName);\n        musicSheetsStore.setValue(backend.getAllSheets());\n        return newSheetDetail;\n    } catch {}\n}\n\n/**\n * 更新歌单信息\n * @param sheetId 歌单ID\n * @param newData 最新的歌单信息\n * @returns\n */\nexport async function updateSheet(\n    sheetId: string,\n    newData: Partial<IMusic.IMusicSheetItem>,\n) {\n    try {\n        await backend.updateSheet(sheetId, newData);\n        musicSheetsStore.setValue(backend.getAllSheets());\n    } catch {}\n}\n\n/**\n * 更新歌单中的歌曲顺序\n * @param sheetId\n * @param musicList\n */\nexport async function updateSheetMusicOrder(\n    sheetId: string,\n    musicList: IMusic.IMusicItem[],\n) {\n    try {\n        const targetSheet = musicSheetsStore\n            .getValue()\n            .find((it) => it.id === sheetId);\n        updateSheetDetail({\n            ...targetSheet,\n            musicList,\n        });\n        await backend.updateSheet(sheetId, {\n            musicList: musicList.map(toMediaBase) as any,\n        });\n        musicSheetsStore.setValue(backend.getAllSheets());\n    } catch {}\n}\n\n/**\n * 移除歌单\n * @param sheetId 歌单ID\n * @returns 删除后的ID\n */\nexport async function removeSheet(sheetId: string) {\n    try {\n        await backend.removeSheet(sheetId);\n        musicSheetsStore.setValue(backend.getAllSheets());\n    } catch {}\n}\n\n/**\n * 清空所有音乐\n * @param sheetId 歌单ID\n * @returns 删除后的ID\n */\nexport async function clearSheet(sheetId: string) {\n    try {\n        await backend.clearSheet(sheetId);\n        musicSheetsStore.setValue(backend.getAllSheets());\n        refetchSheetDetail(sheetId);\n    } catch {}\n}\n\n/**\n * 收藏歌单\n * @param sheet\n */\nexport async function starMusicSheet(sheet: IMedia.IMediaBase) {\n    await backend.starMusicSheet(sheet);\n    starredSheetsStore.setValue(backend.getAllStarredSheets());\n}\n\n/**\n * 取消收藏歌单\n * @param sheet\n */\nexport async function unstarMusicSheet(sheet: IMedia.IMediaBase) {\n    await backend.unstarMusicSheet(sheet);\n    starredSheetsStore.setValue(backend.getAllStarredSheets());\n}\n\n/**\n * 收藏歌单排序\n */\nexport async function setStarredMusicSheets(sheets: IMedia.IMediaBase[]) {\n    await backend.setStarredMusicSheets(sheets);\n    starredSheetsStore.setValue(backend.getAllStarredSheets());\n}\n\n/**************************** 歌曲相关方法 ************************/\n\n/**\n * 添加歌曲到歌单\n * @param musicItems\n * @param sheetId\n * @returns\n */\nexport async function addMusicToSheet(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    sheetId: string,\n) {\n    const start = Date.now();\n    await backend.addMusicToSheet(musicItems, sheetId);\n    console.log(\"添加音乐\", Date.now() - start, \"ms\");\n\n    musicSheetsStore.setValue(backend.getAllSheets());\n    if (sheetId === defaultSheet.id) {\n    // 更新默认列表的状态\n        refreshFavoriteState();\n    }\n    refetchSheetDetail(sheetId);\n}\n\n/** 添加到默认歌单 */\nexport async function addMusicToFavorite(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n) {\n    return addMusicToSheet(musicItems, defaultSheet.id);\n}\n\n/**\n * 从歌单内移除歌曲\n * @param musicItems 要移除的歌曲\n * @param sheetId 歌单ID\n * @returns\n */\nexport async function removeMusicFromSheet(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n    sheetId: string,\n) {\n    const start = Date.now();\n    await backend.removeMusicFromSheet(musicItems, sheetId);\n    console.log(\"删除音乐\", Date.now() - start, \"ms\");\n\n    musicSheetsStore.setValue(backend.getAllSheets());\n    if (sheetId === defaultSheet.id) {\n    // 更新默认列表的状态\n        refreshFavoriteState();\n    }\n    refetchSheetDetail(sheetId);\n}\n\n/** 从默认歌单中移除 */\nexport async function removeMusicFromFavorite(\n    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],\n) {\n    return removeMusicFromSheet(musicItems, defaultSheet.id);\n}\n\n/** 是否是我喜欢的歌单 */\nexport function isFavoriteMusic(musicItem: IMusic.IMusicItem) {\n    return backend.isFavoriteMusic(musicItem);\n}\n\n/** hook 某首歌曲是否被标记成喜欢 */\nexport function useMusicIsFavorite(musicItem: IMusic.IMusicItem) {\n    const [isFav, setIsFav] = useState(backend.isFavoriteMusic(musicItem));\n\n    useEffect(() => {\n        const cb = () => {\n            setIsFav(backend.isFavoriteMusic(musicItem));\n        };\n        cb();\n        refreshFavCbs.add(cb);\n        return () => {\n            refreshFavCbs.delete(cb);\n        };\n    }, [musicItem]);\n\n    return isFav;\n}\n\nconst updateSheetDetailCallbacks: Map<\n    string,\n    Set<(newSheet: IMusic.IMusicSheetItem) => void>\n> = new Map();\n\nfunction updateSheetDetail(newSheet: IMusic.IMusicSheetItem) {\n    updateSheetDetailCallbacks.get(newSheet?.id)?.forEach((cb) => cb?.(newSheet));\n}\n\n/**\n * 重新取歌单状态\n * @param sheetId\n */\nasync function refetchSheetDetail(sheetId: string) {\n    let sheetDetail = await backend.getSheetItemDetail(sheetId);\n    if (!sheetDetail) {\n    // 可能已经被删除了\n        sheetDetail = {\n            id: sheetId,\n            title: \"已删除歌单\",\n            artist: \"未知作者\",\n            platform: localPluginName,\n        };\n    }\n\n    updateSheetDetail(sheetDetail);\n}\n\n/**\n * 监听当前某个歌单\n * @param sheetId 歌单ID\n * @param initQuery 是否重新查询\n */\nexport function useMusicSheet(sheetId: string) {\n    const [pendingState, setPendingState] = useState(\n        RequestStateCode.PENDING_FIRST_PAGE,\n    );\n    const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>(\n        null,\n    );\n\n    // 实时的sheetId\n    const realTimeSheetIdRef = useRef(sheetId);\n    realTimeSheetIdRef.current = sheetId;\n\n    const pendingStateRef = useRef(pendingState);\n    pendingStateRef.current = pendingState;\n\n    useEffect(() => {\n        const updateSheet = async (newSheet: IMusic.IMusicSheetItem) => {\n            // 如果更新的是当前歌单，则设置\n            if (realTimeSheetIdRef.current === newSheet.id) {\n                setSheetItem(newSheet);\n                setPendingState(RequestStateCode.FINISHED);\n            }\n        };\n\n        const cbs = updateSheetDetailCallbacks.get(sheetId) ?? new Set();\n        cbs.add(updateSheet);\n        updateSheetDetailCallbacks.set(sheetId, cbs);\n\n        const targetSheet = musicSheetsStore\n            .getValue()\n            .find((item) => item.id === sheetId);\n\n        if (targetSheet) {\n            setSheetItem({\n                ...targetSheet,\n                musicList: [],\n            });\n        }\n\n        setPendingState(RequestStateCode.PENDING_FIRST_PAGE);\n        refetchSheetDetail(sheetId);\n\n        return () => {\n            cbs?.delete(updateSheet);\n        };\n    }, [sheetId]);\n\n    return [sheetItem, pendingState] as const;\n}\n\n/**\n * 监听当前某个歌单\n * @param sheetId 歌单ID\n * @param initQuery 是否重新查询\n */\n// export function useMusicSheet(sheetId: string) {\n//   const [pendingState, setPendingState] = useState(\n//     RequestStateCode.PENDING_FIRST_PAGE\n//   );\n//   const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>(\n//     null\n//   );\n\n//   // 实时的sheetId\n//   const realTimeSheetIdRef = useRef(sheetId);\n//   realTimeSheetIdRef.current = sheetId;\n\n//   const pendingStateRef = useRef(pendingState);\n//   pendingStateRef.current = pendingState;\n\n//   useEffect(() => {\n//     const updateSheet = async () => {\n//       const start = Date.now();\n//       const sheetDetail = await backend.getSheetItemDetail(sheetId);\n//       console.log(\"歌单详情\", Date.now() - start, \"ms\");\n//       if (realTimeSheetIdRef.current === sheetId) {\n//         console.log(\"歌单详情\", sheetId);\n//         setSheetItem(sheetDetail);\n//         setPendingState(RequestStateCode.FINISHED);\n//       }\n//     };\n\n//     const updateSheetCallback = async () => {\n//       if (!(pendingStateRef.current & RequestStateCode.LOADING)) {\n//         setPendingState(RequestStateCode.PENDING_REST_PAGE);\n//         await updateSheet();\n//       }\n//     };\n\n//     const cbs = updateSheetCbs.get(sheetId) ?? new Set();\n//     cbs.add(updateSheetCallback);\n//     updateSheetCbs.set(sheetId, cbs);\n\n//     const targetSheet = musicSheetsStore\n//       .getValue()\n//       .find((item) => item.id === sheetId);\n\n//     if (targetSheet) {\n//       setSheetItem({\n//         ...targetSheet,\n//         musicList: [],\n//       });\n//     }\n\n//     setPendingState(RequestStateCode.PENDING_FIRST_PAGE);\n//     updateSheet();\n\n//     return () => {\n//       cbs?.delete(updateSheetCallback);\n//     };\n//   }, [sheetId]);\n\n//   return [sheetItem, pendingState] as const;\n// }\n\nexport async function exportAllSheetDetails() {\n    return await backend.exportAllSheetDetails();\n}\n"
  },
  {
    "path": "src/renderer/core/music-sheet/frontend/index.ts",
    "content": "export * from \"./index.old\";\n"
  },
  {
    "path": "src/renderer/core/music-sheet/index.ts",
    "content": "import * as frontend from \"./frontend\";\nimport defaultSheet from \"./common/default-sheet\";\n\nconst MusicSheet = {\n    // ...sheetsMethod,\n    defaultSheet,\n    frontend,\n};\n\nexport default MusicSheet;\nexport { defaultSheet };\n"
  },
  {
    "path": "src/renderer/core/recently-playlist/index.ts",
    "content": "import { isSameMedia } from \"@/common/media-util\";\nimport Store from \"@/common/store\";\nimport {\n    getUserPreferenceIDB,\n    setUserPreferenceIDB,\n} from \"@/renderer/utils/user-perference\";\nimport { Immer } from \"immer\";\nimport { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nconst recentlyPlayListStore = new Store<IMusic.IMusicItem[]>([]);\n\nconst immer = new Immer({\n    autoFreeze: false,\n});\n\nconst HARD_LIMIT = 500;\n\nasync function fetchRecentlyPlaylist() {\n    return (await getUserPreferenceIDB(\"recentlyPlayList\")) || [];\n}\n\nasync function setRecentlyPlaylist(musicItems: IMusic.IMusicItem[]) {\n    recentlyPlayListStore.setValue(musicItems);\n    return await setUserPreferenceIDB(\"recentlyPlayList\", musicItems);\n}\n\nexport async function setupRecentlyPlaylist() {\n    const playList = (await fetchRecentlyPlaylist()).filter(it => !!it);\n\n    recentlyPlayListStore.setValue(playList);\n}\n\nexport async function addToRecentlyPlaylist(musicItem: IMusic.IMusicItem) {\n    if (!musicItem || !musicItem.id || !musicItem.platform) {\n        return;\n    }\n\n    const playList = recentlyPlayListStore.getValue();\n    const existId = playList.findIndex((it) => isSameMedia(musicItem, it));\n    let newPlayList = playList;\n\n    if (existId !== -1) {\n        newPlayList = immer.produce(playList, (draft) => {\n            draft.splice(existId, 1);\n        });\n    }\n    newPlayList = [musicItem].concat(newPlayList).slice(0, HARD_LIMIT);\n    setRecentlyPlaylist(newPlayList);\n}\n\nexport async function removeRecentlyPlayList(musicItem: IMusic.IMusicItem) {\n    const playList = recentlyPlayListStore.getValue();\n    const existId = playList.findIndex((it) => isSameMedia(musicItem, it));\n    let newPlayList = playList;\n\n    if (existId !== -1) {\n        newPlayList = immer.produce(playList, (draft) => {\n            draft.splice(existId, 1);\n        });\n        setRecentlyPlaylist(newPlayList);\n    }\n}\n\nexport async function clearRecentlyPlaylist() {\n    setRecentlyPlaylist([]);\n}\n\nexport function useRecentlyPlaylistSheet() {\n    const recentlyPlayList = recentlyPlayListStore.useValue();\n    const { t } = useTranslation();\n\n    const musicSheet: IMusic.IMusicSheetItem = useMemo(() => {\n        return {\n            id: \"recently-play\",\n            title: t(\"side_bar.recently_play\"),\n            platform: \"recently-play\",\n            playCount: recentlyPlayList?.length || 0,\n            artwork: recentlyPlayList?.[0]?.artwork,\n            musicList: recentlyPlayList || [],\n        };\n    }, [recentlyPlayList, t]);\n\n    return musicSheet;\n}\n"
  },
  {
    "path": "src/renderer/core/track-player/controller/audio-controller.ts",
    "content": "/**\n * 播放音乐\n */\nimport { encodeUrlHeaders } from \"@/common/normalize-util\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport getUrlExt from \"@/renderer/utils/get-url-ext\";\nimport Hls, { Events as HlsEvents, HlsConfig } from \"hls.js\";\nimport { isSameMedia } from \"@/common/media-util\";\nimport { PlayerState } from \"@/common/constant\";\nimport ServiceManager from \"@shared/service-manager/renderer\";\nimport ControllerBase from \"@renderer/core/track-player/controller/controller-base\";\nimport { ErrorReason } from \"@renderer/core/track-player/enum\";\nimport Dexie from \"dexie\";\nimport voidCallback from \"@/common/void-callback\";\nimport { IAudioController } from \"@/types/audio-controller\";\nimport Promise = Dexie.Promise;\n\n\nclass AudioController extends ControllerBase implements IAudioController {\n    private audio: HTMLAudioElement;\n    private hls: Hls;\n\n    private _playerState: PlayerState = PlayerState.None;\n    get playerState() {\n        return this._playerState;\n    }\n    set playerState(value: PlayerState) {\n        if (this._playerState !== value) {\n            this.onPlayerStateChanged?.(value);\n        }\n        this._playerState = value;\n\n    }\n\n    public musicItem: IMusic.IMusicItem | null = null;\n\n    get hasSource() {\n        return !!this.audio.src;\n    }\n\n    constructor() {\n        super();\n        this.audio = new Audio();\n        this.audio.preload = \"auto\";\n        this.audio.controls = false;\n\n        ////// events\n        this.audio.onplaying = () => {\n            this.playerState = PlayerState.Playing;\n            navigator.mediaSession.playbackState = \"playing\";\n        };\n\n        this.audio.onpause = () => {\n            this.playerState = PlayerState.Paused;\n            navigator.mediaSession.playbackState = \"paused\";\n        };\n\n        this.audio.onerror = (event) => {\n            this.playerState = PlayerState.Paused;\n            navigator.mediaSession.playbackState = \"paused\";\n            this.onError?.(ErrorReason.EmptyResource, event as any);\n        };\n\n        this.audio.ontimeupdate = () => {\n            this.onProgressUpdate?.({\n                currentTime: this.audio.currentTime,\n                duration: this.audio.duration, // 缓冲中是Infinity\n            });\n        };\n\n        // this.audio.onseeking = () => {\n        //     this.playerState = PlayerState.Buffering;\n        // }\n        //\n        // this.audio.onseeked = () => {\n        //     this.playerState = PlayerState.Playing;\n        // }\n\n        this.audio.onended = () => {\n            this.playerState = PlayerState.Paused;\n            this.onEnded?.();\n        };\n\n        this.audio.onvolumechange = () => {\n            this.onVolumeChange?.(this.audio.volume);\n        };\n\n        this.audio.onratechange = () => {\n            this.onSpeedChange?.(this.audio.playbackRate);\n        };\n\n\n        // @ts-ignore  isDev\n        window.ad = this.audio;\n    }\n\n    private initHls(config?: Partial<HlsConfig>) {\n        if (!this.hls) {\n            this.hls = new Hls(config);\n            this.hls.attachMedia(this.audio);\n            this.hls.on(HlsEvents.ERROR, (evt, error) => {\n                this.onError(ErrorReason.EmptyResource, error);\n            });\n        }\n    }\n\n    private destroyHls() {\n        if (this.hls) {\n            this.hls.detachMedia();\n            this.hls.off(HlsEvents.ERROR);\n            this.hls.destroy();\n            this.hls = null;\n        }\n    }\n\n    destroy(): void {\n        this.destroyHls();\n        this.reset();\n    }\n\n    pause(): void {\n        if (this.hasSource) {\n            this.audio.pause();\n        }\n    }\n\n    play(): void {\n        if (this.hasSource) {\n            this.audio.play().catch(voidCallback);\n        }\n    }\n\n    reset(): void {\n        this.playerState = PlayerState.None;\n        this.audio.src = \"\";\n        this.audio.removeAttribute(\"src\");\n        navigator.mediaSession.metadata = null;\n        navigator.mediaSession.playbackState = \"none\";\n    }\n\n    seekTo(seconds: number): void {\n        if (this.hasSource && isFinite(seconds)) {\n            const duration = this.audio.duration;\n            this.audio.currentTime = Math.min(\n                seconds,\n                isNaN(duration) ? Infinity : duration,\n            );\n        }\n    }\n\n    setLoop(isLoop: boolean): void {\n        this.audio.loop = isLoop;\n    }\n\n    setSinkId(deviceId: string): Promise<void> {\n        return (this.audio as any).setSinkId(deviceId);\n    }\n\n    setSpeed(speed: number): void {\n        this.audio.defaultPlaybackRate = speed;\n        this.audio.playbackRate = speed;\n    }\n\n    prepareTrack(musicItem: IMusic.IMusicItem) {\n        this.musicItem = { ...musicItem };\n\n        // 1. update metadata\n        navigator.mediaSession.metadata = new MediaMetadata({\n            title: musicItem.title,\n            artist: musicItem.artist,\n            album: musicItem.album,\n            artwork: [\n                {\n                    src: musicItem.artwork ?? albumImg,\n                },\n            ],\n        });\n\n        // 2. reset track\n        this.playerState = PlayerState.None;\n        this.audio.src = \"\";\n        this.audio.removeAttribute(\"src\");\n        navigator.mediaSession.playbackState = \"none\";\n    }\n\n    setTrackSource(trackSource: IMusic.IMusicSource, musicItem: IMusic.IMusicItem): void {\n        this.musicItem = { ...musicItem };\n\n        // 1. update metadata\n        navigator.mediaSession.metadata = new MediaMetadata({\n            title: musicItem.title,\n            artist: musicItem.artist,\n            album: musicItem.album,\n            artwork: [\n                {\n                    src: musicItem.artwork ?? albumImg,\n                },\n            ],\n        });\n\n\n        // 2. convert url and headers\n        let url = trackSource.url;\n        const urlObj = new URL(trackSource.url);\n        let headers: Record<string, any> | null = null;\n\n        // 2.1 convert user agent\n        if (trackSource.headers || trackSource.userAgent) {\n            headers = { ...(trackSource.headers ?? {}) };\n            if (trackSource.userAgent) {\n                headers[\"user-agent\"] = trackSource.userAgent;\n            }\n        }\n\n        // 2.2 convert auth header\n        if (urlObj.username && urlObj.password) {\n            const authHeader = `Basic ${btoa(\n                `${decodeURIComponent(urlObj.username)}:${decodeURIComponent(\n                    urlObj.password,\n                )}`,\n            )}`;\n            urlObj.username = \"\";\n            urlObj.password = \"\";\n            headers = {\n                ...(headers || {}),\n                Authorization: authHeader,\n            };\n            url = urlObj.toString();\n        }\n\n        // 2.3 hack url with headers\n        if (headers) {\n            const forwardedUrl = ServiceManager.RequestForwarderService.forwardRequest(url, \"GET\", headers);\n            if (forwardedUrl) {\n                url = forwardedUrl;\n                headers = null;\n            } else if (!headers[\"Authorization\"]) {\n                url = encodeUrlHeaders(url, headers);\n                headers = null;\n            }\n        }\n\n        if (!url) {\n            this.onError(ErrorReason.EmptyResource, new Error(\"url is empty\"));\n            return;\n        }\n\n        // 3. set real source\n        if (getUrlExt(trackSource.url) === \".m3u8\") {\n            if (Hls.isSupported()) {\n                this.initHls();\n                this.hls.loadSource(url);\n            } else {\n                this.onError(ErrorReason.UnsupportedResource);\n                return;\n            }\n        } else if (headers) {\n            fetch(url, {\n                method: \"GET\",\n                headers: {\n                    ...trackSource.headers,\n                },\n            })\n                .then(async (res) => {\n                    const blob = await res.blob();\n                    if (isSameMedia(this.musicItem, musicItem)) {\n                        this.audio.src = URL.createObjectURL(blob);\n                    }\n                });\n        } else {\n            this.audio.src = url;\n        }\n    }\n\n    setVolume(volume: number): void {\n        this.audio.volume = volume;\n    }\n}\n\nexport default AudioController;\n"
  },
  {
    "path": "src/renderer/core/track-player/controller/controller-base.ts",
    "content": "import { CurrentTime, ErrorReason } from \"@renderer/core/track-player/enum\";\nimport { PlayerState } from \"@/common/constant\";\n\nexport default class ControllerBase {\n    public onPlayerStateChanged?: (state: PlayerState) => void;\n    // 进度更新\n    public onProgressUpdate?: (progress: CurrentTime) => void;\n    // 出错\n    public onError?: (type: ErrorReason, error?: any) => void;\n    // 播放结束\n    public onEnded?: () => void;\n    // 音量改变\n    public onVolumeChange?: (volume: number) => void;\n    // 速度改变\n    public onSpeedChange?: (speed: number) => void;\n\n}\n"
  },
  {
    "path": "src/renderer/core/track-player/enum.ts",
    "content": "import LyricParser, { IParsedLrcItem } from \"@/renderer/utils/lyric-parser\";\n\n/** 错误信息 */\nexport enum ErrorReason {\n    /** 音源为空 */\n    EmptyResource,\n    /** 不支持的类型 */\n    UnsupportedResource,\n}\n\nexport interface ICurrentLyric {\n    parser?: LyricParser;\n    currentLrc?: IParsedLrcItem;\n}\n\n/** 播放器事件 */\nexport enum PlayerEvents {\n    /** 播放失败 */\n    Error = \"play-back-error\",\n    /** 播放状态改变 */\n    StateChanged = \"play-state-changed\",\n    /** 进度更新 */\n    ProgressChanged = \"time-updated\",\n    /** 音乐改变 */\n    MusicChanged = \"music-changed\",\n    /** 音量改变 */\n    VolumeChanged = \"volume-changed\",\n    /** 速度改变 */\n    SpeedChanged = \"speed-changed\",\n    /** 播放结束 */\n    // PlayEnd = \"play-end\",\n    /** modechange */\n    RepeatModeChanged = \"repeat-mode-changed\",\n    /** 歌词改变 */\n    CurrentLyricChanged = \"current-lyric-changed\",\n    /** 整体歌词改变 */\n    LyricChanged = \"lyric-changed\",\n}\n\n\n/** 当前时间信息 */\nexport interface CurrentTime {\n    currentTime: number;\n    duration: number;\n}\n"
  },
  {
    "path": "src/renderer/core/track-player/hooks.ts",
    "content": "import _trackPlayerStore from \"@renderer/core/track-player/store\";\n\nconst {\n    musicQueueStore,\n    currentMusicStore,\n    currentLyricStore,\n    repeatModeStore,\n    progressStore,\n    playerStateStore,\n    currentVolumeStore,\n    currentSpeedStore,\n    currentQualityStore,\n} = _trackPlayerStore;\n\nexport const useCurrentMusic = currentMusicStore.useValue;\n\nexport const useProgress = progressStore.useValue;\n\nexport const usePlayerState = playerStateStore.useValue;\n\nexport const useRepeatMode = repeatModeStore.useValue;\n\nexport const useMusicQueue = musicQueueStore.useValue;\n\nexport const useLyric = currentLyricStore.useValue;\n\nexport const useVolume = currentVolumeStore.useValue;\n\nexport const useSpeed = currentSpeedStore.useValue;\n\nexport const useQuality = currentQualityStore.useValue;\n"
  },
  {
    "path": "src/renderer/core/track-player/index.ts",
    "content": "import { CurrentTime, ICurrentLyric, PlayerEvents } from \"./enum\";\nimport shuffle from \"lodash.shuffle\";\nimport {\n    addSortProperty,\n    getInternalData,\n    getQualityOrder,\n    isSameMedia,\n    sortByTimestampAndIndex,\n} from \"@/common/media-util\";\nimport { PlayerState, RepeatMode, sortIndexSymbol, timeStampSymbol } from \"@/common/constant\";\nimport LyricParser, { IParsedLrcItem } from \"@/renderer/utils/lyric-parser\";\nimport {\n    getUserPreference,\n    getUserPreferenceIDB,\n    removeUserPreference,\n    setUserPreference,\n    setUserPreferenceIDB,\n} from \"@/renderer/utils/user-perference\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { createIndexMap, IIndexMap } from \"@/common/index-map\";\nimport _trackPlayerStore from \"./store\";\nimport EventEmitter from \"eventemitter3\";\nimport { IAudioController } from \"@/types/audio-controller\";\nimport AudioController from \"@renderer/core/track-player/controller/audio-controller\";\nimport logger from \"@shared/logger/renderer\";\nimport voidCallback from \"@/common/void-callback\";\nimport { delay } from \"@/common/time-util\";\nimport { createUniqueMap } from \"@/common/unique-map\";\nimport { getLinkedLyric } from \"@renderer/core/link-lyric\";\nimport { fsUtil } from \"@shared/utils/renderer\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nconst {\n    musicQueueStore,\n    currentMusicStore,\n    currentLyricStore,\n    repeatModeStore,\n    progressStore,\n    playerStateStore,\n    currentVolumeStore,\n    currentSpeedStore,\n    currentQualityStore,\n    resetProgress,\n} = _trackPlayerStore;\n\n\ninterface InternalPlayerEvents {\n    [PlayerEvents.RepeatModeChanged]: (repeatMode: RepeatMode) => void;\n    [PlayerEvents.MusicChanged]: (musicItem: IMusic.IMusicItem | null) => void;\n    [PlayerEvents.LyricChanged]: (parser: LyricParser | null) => void;\n    [PlayerEvents.CurrentLyricChanged]: (lyric: IParsedLrcItem | null) => void;\n    [PlayerEvents.Error]: (errorMusicItem: IMusic.IMusicItem | null, reason: any) => void;\n    [PlayerEvents.ProgressChanged]: (progress: CurrentTime) => void;\n    [PlayerEvents.StateChanged]: (state: PlayerState) => void;\n}\n\ninterface IPlayOptions {\n    refreshSource?: boolean;\n    restartOnSameMedia?: boolean;\n    seekTo?: number;\n    quality?: IMusic.IQualityKey;\n}\n\ninterface ITrackOptions {\n    seekTo?: number;\n    // 自动播放\n    autoPlay?: boolean;\n}\n\nclass TrackPlayer {\n    get currentMusic() {\n        return currentMusicStore.getValue();\n    }\n\n    // 只有基础信息\n    get currentMusicBasicInfo() {\n        const currentMusic = this.currentMusic;\n        if (!currentMusic) {\n            return null;\n        }\n\n        return {\n            platform: currentMusic.platform,\n            title: currentMusic.title,\n            artist: currentMusic.artist,\n            id: currentMusic.id,\n            album: currentMusic.album,\n            artwork: currentMusic.artwork,\n        } as IMusic.IMusicItem;\n    }\n\n    get progress() {\n        return progressStore.getValue();\n    }\n\n    get playerState() {\n        return playerStateStore.getValue();\n    }\n\n    get repeatMode() {\n        return repeatModeStore.getValue();\n    }\n\n    get currentQuality() {\n        return currentQualityStore.getValue();\n    }\n\n    get speed() {\n        return currentSpeedStore.getValue();\n    }\n\n    get volume() {\n        return currentVolumeStore.getValue();\n    }\n\n    get lyric() {\n        return currentLyricStore.getValue();\n    }\n\n    get musicQueue() {\n        return musicQueueStore.getValue();\n    }\n\n    get isEmpty() {\n        return this.musicQueue.length <= 0;\n    }\n\n    private indexMap: IIndexMap;\n\n    private currentIndex = -1;\n\n    private audioController: IAudioController;\n\n    private ee: EventEmitter<InternalPlayerEvents>;\n\n    constructor() {\n        this.indexMap = createIndexMap();\n        this.ee = new EventEmitter();\n        this.audioController = new AudioController();\n    }\n\n    on<T extends keyof InternalPlayerEvents>(event: T, callback: InternalPlayerEvents[T]) {\n        this.ee.on(event, callback as any);\n    }\n\n    private setupEvents() {\n        this.ee.on(PlayerEvents.Error, async (errorMusicItem) => {\n            // config\n            const needSkip = AppConfig.getConfig(\"playMusic.playError\") === \"skip\";\n\n            this.resetProgress();\n            if (this.musicQueue.length > 1 && needSkip) {\n                await delay(500);\n                if (this.isCurrentMusic(errorMusicItem)) {\n                    this.skipToNext();\n                }\n            }\n        });\n\n        navigator.mediaSession.setActionHandler(\"nexttrack\", () => {\n            this.skipToNext();\n        });\n\n        navigator.mediaSession.setActionHandler(\"previoustrack\", () => {\n            this.skipToPrev();\n        });\n    }\n\n\n    private createAudioController() {\n        const audioController = new AudioController();\n        // 播放结束\n        audioController.onEnded = () => {\n            this.resetProgress();\n\n            switch (this.repeatMode) {\n                case RepeatMode.Queue:\n                case RepeatMode.Shuffle: {\n                    this.skipToNext();\n                    break;\n                }\n                case RepeatMode.Loop: {\n                    this.playIndex(this.currentIndex, {\n                        restartOnSameMedia: true,\n                    });\n                }\n            }\n        };\n        // 进度更新\n        audioController.onProgressUpdate = ((progress) => {\n            this.setProgress(progress);\n            // 检查歌词\n            if (this.lyric?.parser) {\n                const lyricItem = this.lyric.parser.getPosition(progress.currentTime);\n                if (this.lyric.currentLrc?.lrc !== lyricItem?.lrc) {\n                    this.setCurrentLyric({\n                        parser: this.lyric.parser,\n                        currentLrc: lyricItem,\n                    });\n                }\n            }\n        });\n\n        audioController.onVolumeChange = (volume) => {\n            currentVolumeStore.setValue(volume);\n            setUserPreference(\"volume\", volume);\n        };\n\n        audioController.onSpeedChange = (speed) => {\n            currentSpeedStore.setValue(speed);\n            setUserPreference(\"speed\", speed);\n        };\n\n        audioController.onPlayerStateChanged = (state) => {\n            this.setPlayerState(state);\n        };\n\n        audioController.onError = async (type, reason) => {\n            this.ee.emit(PlayerEvents.Error, audioController.musicItem, reason);\n        };\n\n\n        this.audioController = audioController;\n    }\n\n    public async setup() {\n        // 1. Config\n        const [repeatMode, currentMusic, currentProgress, volume, speed, defaultQuality] = [\n            getUserPreference(\"repeatMode\"),\n            getUserPreference(\"currentMusic\"),\n            getUserPreference(\"currentProgress\"),\n            getUserPreference(\"volume\"),\n            getUserPreference(\"speed\"),\n            getUserPreference(\"currentQuality\") || AppConfig.getConfig(\"playMusic.defaultQuality\"),\n        ];\n        const playList = ((await getUserPreferenceIDB(\"playList\")) ?? []).filter(it => !!it);\n        addSortProperty(playList);\n        const deviceId = AppConfig.getConfig(\"playMusic.audioOutputDevice\")?.deviceId;\n\n        // 2. init audio controller\n        this.createAudioController();\n        this.setupEvents();\n\n        // 3. resume state\n        musicQueueStore.setValue(playList);\n        this.indexMap.update(playList);\n\n        if (repeatMode) {\n            this.setRepeatMode(repeatMode as RepeatMode);\n        }\n\n        this.setCurrentMusic(currentMusic);\n        this.currentIndex = this.findMusicIndex(currentMusic);\n\n        if (deviceId) {\n            this.setAudioOutputDevice(deviceId);\n        }\n\n        if (volume !== null && volume !== undefined) {\n            this.setVolume(volume);\n        }\n\n        if (speed) {\n            this.setSpeed(speed);\n        }\n\n        // 4. reload lyric\n        this.fetchCurrentLyric();\n\n        // 5. fetch music source\n        this.fetchMediaSource(currentMusic, defaultQuality).then(({ mediaSource, quality }) => {\n            if (this.isCurrentMusic(currentMusic)) {\n                this.setTrack(mediaSource, currentMusic, {\n                    seekTo: currentProgress,\n                    autoPlay: false,\n                });\n                this.setCurrentQuality(quality);\n            }\n        }).catch(voidCallback);\n    }\n\n\n    // 切换播放模式\n    public toggleRepeatMode() {\n        let nextRepeatMode = this.repeatMode;\n        switch (nextRepeatMode) {\n            case RepeatMode.Shuffle:\n                nextRepeatMode = RepeatMode.Loop;\n                break;\n            case RepeatMode.Loop:\n                nextRepeatMode = RepeatMode.Queue;\n                break;\n            case RepeatMode.Queue:\n                nextRepeatMode = RepeatMode.Shuffle;\n                break;\n        }\n\n        this.setRepeatMode(nextRepeatMode);\n    }\n\n    public async playIndex(index: number, options: IPlayOptions = {}) {\n        const { refreshSource, restartOnSameMedia = true, seekTo, quality: intendedQuality } = options;\n        if (index === -1 && this.musicQueue.length === 0) {\n            // 播放列表为空\n            return;\n        }\n        // 1. normalize index\n        index = (index + this.musicQueue.length) % this.musicQueue.length;\n\n        // 2. same media\n        if (this.currentIndex === index && this.isCurrentMusic(this.musicQueue[index]) && !refreshSource) {\n            if (restartOnSameMedia) {\n                this.seekTo(0);\n            }\n            this.audioController.play();\n\n            return;\n        }\n\n        // update music\n        const nextMusicItem = this.musicQueue[index];\n        this.setCurrentMusic(nextMusicItem);\n        this.currentIndex = index;\n\n        this.setPlayerState(PlayerState.Buffering);\n        this.audioController.prepareTrack?.(nextMusicItem);\n\n        try {\n            const { mediaSource, quality } = await this.fetchMediaSource(nextMusicItem, intendedQuality);\n\n            if (!mediaSource.url) {\n                throw new Error(\"mediaSource.url is empty\");\n            }\n\n            if (!this.isCurrentMusic(nextMusicItem)) {\n                // should be aborted\n                return;\n            }\n\n            this.setCurrentQuality(quality);\n            this.setTrack(mediaSource, nextMusicItem, {\n                seekTo,\n                autoPlay: true,\n            });\n\n            // extra information\n            const musicInfo = await PluginManager.callPluginDelegateMethod(\n                {\n                    platform: nextMusicItem.platform,\n                },\n                \"getMusicInfo\",\n                nextMusicItem,\n            ).catch(voidCallback);\n\n            if (!(musicInfo && this.isCurrentMusic(nextMusicItem) && typeof musicInfo === \"object\")) {\n                return;\n            }\n\n            this.setCurrentMusic({\n                ...nextMusicItem,\n                ...musicInfo,\n                platform: nextMusicItem.platform,\n                id: nextMusicItem.id,\n            });\n\n        } catch (e) {\n            // 播放失败\n            this.setCurrentQuality(AppConfig.getConfig(\"playMusic.defaultQuality\"));\n            this.audioController.reset();\n            this.ee.emit(PlayerEvents.Error, nextMusicItem, e);\n        }\n\n\n    }\n\n    public async playMusic(musicItem: IMusic.IMusicItem, options: IPlayOptions = {}) {\n        if (!musicItem) {\n            return;\n        }\n        const queueIndex = this.findMusicIndex(musicItem);\n        if (queueIndex === -1) {\n            // TODO: 用add代替\n            const newQueue = [\n                ...this.musicQueue,\n                {\n                    ...musicItem,\n                    [timeStampSymbol]: Date.now(),\n                    [sortIndexSymbol]: 0,\n                },\n            ];\n            this.setMusicQueue(newQueue);\n            await this.playIndex(newQueue.length - 1, options);\n        } else {\n            await this.playIndex(queueIndex, options);\n        }\n    }\n\n    public async playMusicWithReplaceQueue(musicList: IMusic.IMusicItem[], musicItem?: IMusic.IMusicItem) {\n        if (!musicList.length && !musicItem) {\n            return;\n        }\n        addSortProperty(musicList);\n        if (this.repeatMode === RepeatMode.Shuffle) {\n            musicList = shuffle(musicList);\n        }\n        musicItem = musicItem ?? musicList[0];\n        this.setMusicQueue(musicList);\n        await this.playMusic(musicItem);\n    }\n\n    public skipToPrev() {\n        if (this.isEmpty) {\n            this.setCurrentMusic(null);\n            this.currentIndex = -1;\n            return;\n        }\n        this.playIndex(this.currentIndex - 1);\n    }\n\n    public skipToNext() {\n        if (this.isEmpty) {\n            this.setCurrentMusic(null);\n            this.currentIndex = -1;\n            return;\n        }\n        this.playIndex(this.currentIndex + 1);\n    }\n\n\n    // 重置播放状态\n    public reset() {\n        this.audioController.reset();\n        this.setMusicQueue([]);\n        this.setCurrentMusic(null);\n        this.resetProgress();\n        this.currentIndex = -1;\n\n    }\n\n    public seekTo(seconds: number) {\n        this.audioController.seekTo(seconds);\n    }\n\n    public pause() {\n        this.audioController.pause();\n        if (this.playerState !== this.audioController.playerState) {\n            this.setPlayerState(this.audioController.playerState);\n        }\n    }\n\n    public resume() {\n        this.audioController.play();\n\n        if (this.playerState !== this.audioController.playerState) {\n            this.setPlayerState(this.audioController.playerState);\n        }\n    }\n\n    public setVolume(volume: number) {\n        this.audioController.setVolume(volume);\n    }\n\n    public setSpeed(speed: number) {\n        this.audioController.setSpeed(speed);\n    }\n\n    public addNext(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) {\n        let _musicItems: IMusic.IMusicItem[];\n        if (Array.isArray(musicItems)) {\n            _musicItems = musicItems;\n        } else {\n            _musicItems = [musicItems];\n        }\n\n        const now = Date.now();\n\n        let duplicateIndex = -1;\n        _musicItems.forEach((item, index) => {\n            _musicItems[index] = {\n                ...item,\n                [timeStampSymbol]: now,\n                [sortIndexSymbol]: index,\n            };\n            if (duplicateIndex === -1 && this.isCurrentMusic(item)) {\n                duplicateIndex = index;\n            }\n        });\n\n        if (duplicateIndex !== -1) {\n            _musicItems = [\n                _musicItems[duplicateIndex],\n                ..._musicItems.slice(0, duplicateIndex),\n                ..._musicItems.slice(duplicateIndex + 1),\n            ];\n        }\n\n\n        const startPart = [];\n        const tailPart = [];\n\n        const oldQueue = this.musicQueue;\n        const uniqueMap = createUniqueMap(_musicItems);\n\n        for (let i = 0; i < oldQueue.length; ++i) {\n            if (i <= this.currentIndex) {\n                if (!uniqueMap.has(oldQueue[i])) {\n                    startPart.push(oldQueue[i]);\n                }\n            } else {\n                if (!uniqueMap.has(oldQueue[i])) {\n                    tailPart.push(oldQueue[i]);\n                }\n            }\n        }\n\n\n        const newQueue = [\n            ...startPart,\n            ..._musicItems,\n            ...tailPart,\n        ];\n\n        this.setMusicQueue(newQueue);\n    }\n\n\n    public removeMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[] | number) {\n        if (Array.isArray(musicItems)) {\n            const uniqueMap = createUniqueMap(musicItems);\n\n            const newQueue = [];\n            const oldQueue = this.musicQueue;\n\n            for (let i = 0; i < oldQueue.length; i++) {\n                const musicItem = oldQueue[i];\n                if (uniqueMap.has(musicItem)) {\n                    if (this.currentIndex === i) {\n                        this.audioController.reset();\n                        this.currentIndex = -1;\n                        resetProgress();\n                        this.setCurrentMusic(null);\n                    }\n                } else {\n                    newQueue.push(musicItem);\n                    if (this.currentIndex === i) {\n                        this.currentIndex = newQueue.length - 1;\n                    }\n                }\n            }\n            this.setMusicQueue(newQueue);\n        } else {\n\n            const musicIndex = typeof musicItems === \"number\" ? musicItems : this.findMusicIndex(musicItems);\n            if (musicIndex === -1) {\n                return;\n            }\n            if (musicIndex === this.currentIndex) {\n                this.audioController.reset();\n                this.currentIndex = -1;\n                resetProgress();\n                this.setCurrentMusic(null);\n            }\n\n            const newQueue = [...this.musicQueue];\n            newQueue.splice(musicIndex, 1);\n            this.setMusicQueue(newQueue);\n        }\n    }\n\n\n    public async setQuality(quality: IMusic.IQualityKey) {\n        const currentMusic = this.currentMusic;\n        if (currentMusic && quality !== this.currentQuality) {\n            const { mediaSource, quality: realQuality } = await this.fetchMediaSource(currentMusic, quality);\n            if (this.isCurrentMusic(currentMusic)) {\n                this.setTrack(mediaSource, currentMusic, {\n                    seekTo: this.progress.currentTime ?? 0,\n                    autoPlay: this.playerState === PlayerState.Playing,\n                });\n                this.setCurrentQuality(realQuality);\n            }\n        }\n    }\n\n    public setRepeatMode(repeatMode: RepeatMode) {\n        if (repeatMode === RepeatMode.Shuffle) {\n            this.setMusicQueue(shuffle(this.musicQueue));\n        } else if (this.repeatMode === RepeatMode.Shuffle) {\n            this.setMusicQueue(sortByTimestampAndIndex(this.musicQueue, true));\n        }\n        repeatModeStore.setValue(repeatMode);\n        setUserPreference(\"repeatMode\", repeatMode);\n        this.ee.emit(PlayerEvents.RepeatModeChanged, repeatMode);\n    }\n\n    public async setAudioOutputDevice(deviceId?: string) {\n        try {\n            await this.audioController.setSinkId(deviceId ?? \"\");\n        } catch (e) {\n            logger.logError(\"设置音频输出设备失败\", e);\n        }\n    }\n\n    public setMusicQueue(musicQueue: IMusic.IMusicItem[]) {\n        musicQueueStore.setValue(musicQueue);\n        setUserPreferenceIDB(\"playList\", musicQueue);\n        this.indexMap.update(musicQueue);\n        this.currentIndex = this.findMusicIndex(this.currentMusic);\n    }\n\n\n    public async fetchCurrentLyric(forceLoad = false) {\n        const currentMusic = this.currentMusic;\n\n        if (!currentMusic) {\n            this.setCurrentLyric(null);\n            return;\n        }\n\n        const currentLyric = this.lyric;\n        if (!forceLoad && currentLyric && this.isCurrentMusic(currentLyric?.parser?.musicItem)) {\n            return;\n        }\n        try {\n            // 获取被关联的歌词\n            const linkedLyricItem = await getLinkedLyric(currentMusic);\n            let lyricSource: ILyric.ILyricSource;\n\n            if (linkedLyricItem) {\n                lyricSource = await PluginManager.callPluginDelegateMethod(\n                    linkedLyricItem,\n                    \"getLyric\",\n                    linkedLyricItem,\n                );\n            }\n            if (!lyricSource && this.isCurrentMusic(currentMusic)) {\n                lyricSource = await PluginManager.callPluginDelegateMethod(\n                    currentMusic,\n                    \"getLyric\",\n                    currentMusic,\n                );\n            }\n\n            if (!this.isCurrentMusic(currentMusic)) {\n                return;\n            }\n\n            if (!lyricSource?.rawLrc && !lyricSource?.translation) {\n                this.setCurrentLyric({});\n            }\n            const parser = new LyricParser(lyricSource.rawLrc, {\n                musicItem: currentMusic,\n                translation: lyricSource.translation,\n            });\n\n            this.setCurrentLyric({\n                parser,\n                currentLrc: parser.getPosition(this.progress.currentTime || 0),\n            });\n        } catch (e) {\n            logger.logError(\"歌词解析失败\", e);\n            this.setCurrentLyric({});\n        }\n\n\n    }\n\n\n    private async fetchMediaSource(musicItem: IMusic.IMusicItem, quality?: IMusic.IQualityKey) {\n        const defaultQuality = AppConfig.getConfig(\"playMusic.defaultQuality\");\n        const whenQualityMissing = AppConfig.getConfig(\"playMusic.whenQualityMissing\");\n\n        const qualityOrder = getQualityOrder(quality ?? defaultQuality, whenQualityMissing);\n\n        let mediaSource: IPlugin.IMediaSourceResult | null = null;\n        let realQuality: IMusic.IQualityKey = qualityOrder[0];\n\n        // 1. 判断是否已下载\n        const downloadedData = getInternalData<IMusic.IMusicItemInternalData>(\n            musicItem,\n            \"downloadData\",\n        );\n        if (downloadedData) {\n            const { quality, path: _path } = downloadedData;\n            if (await fsUtil.isFile(_path)) {\n                return {\n                    quality,\n                    mediaSource: {\n                        url: fsUtil.addFileScheme(_path),\n                    },\n                };\n            } else {\n                // TODO 删除\n            }\n        }\n\n        // 2. 如果没有下载\n        for (const quality of qualityOrder) {\n            try {\n                mediaSource = await PluginManager.callPluginDelegateMethod(\n                    {\n                        platform: musicItem.platform,\n                    },\n                    \"getMediaSource\",\n                    musicItem,\n                    quality,\n                );\n                if (!mediaSource?.url) {\n                    continue;\n                }\n                realQuality = quality;\n                break;\n            } catch {\n                // pass\n            }\n        }\n        return {\n            quality: realQuality,\n            mediaSource: mediaSource,\n        };\n    }\n\n\n    // 只读数据的设置\n    private setCurrentMusic(musicItem: IMusic.IMusicItem | null) {\n        if (!this.isCurrentMusic(musicItem)) {\n            currentMusicStore.setValue(musicItem);\n            this.ee.emit(PlayerEvents.MusicChanged, musicItem);\n            this.fetchCurrentLyric();\n            this.setCurrentLyric(null);\n\n            if (musicItem) {\n                setUserPreference(\"currentMusic\", musicItem);\n            } else {\n                removeUserPreference(\"currentMusic\");\n            }\n        } else {\n            // 相同的歌曲，不需要额外触发事件\n            currentMusicStore.setValue(musicItem);\n        }\n    }\n\n    private setProgress(progress: CurrentTime) {\n        progressStore.setValue(progress);\n        setUserPreference(\"currentProgress\", progress.currentTime);\n        this.ee.emit(PlayerEvents.ProgressChanged, progress);\n    }\n\n    private setCurrentQuality(quality: IMusic.IQualityKey) {\n        setUserPreference(\"currentQuality\", quality);\n        currentQualityStore.setValue(quality);\n    }\n\n    private setCurrentLyric(lyric?: ICurrentLyric) {\n        const prev = this.lyric;\n        currentLyricStore.setValue(lyric);\n\n        if (lyric?.parser !== prev?.parser) {\n            this.ee.emit(PlayerEvents.LyricChanged, lyric?.parser ?? null);\n        } else if (lyric?.currentLrc !== prev?.currentLrc) {\n            this.ee.emit(PlayerEvents.CurrentLyricChanged, lyric?.currentLrc ?? null);\n        }\n    }\n\n    private setPlayerState(playerState: PlayerState) {\n        playerStateStore.setValue(playerState);\n        this.ee.emit(PlayerEvents.StateChanged, playerState);\n    }\n\n    // 获取音乐在播放列表中的下标\n    private findMusicIndex(musicItem?: IMusic.IMusicItem | null) {\n        if (!musicItem) {\n            return -1;\n        }\n        return this.indexMap.indexOf(musicItem);\n    }\n\n\n    private resetProgress() {\n        resetProgress();\n        removeUserPreference(\"currentProgress\");\n    }\n\n    private setTrack(mediaSource: IPlugin.IMediaSourceResult, musicItem: IMusic.IMusicItem, options: ITrackOptions = {\n        autoPlay: true,\n    }) {\n        this.resetProgress();\n        this.audioController.setTrackSource(mediaSource, musicItem);\n\n        if (options.seekTo >= 0) {\n            this.audioController.seekTo(options.seekTo);\n        }\n\n        if (options.autoPlay) {\n            this.audioController.play();\n        }\n    }\n\n\n    // 判断某首歌是否是当前播放的歌曲\n    public isCurrentMusic(musicItem: IMusic.IMusicItem) {\n        return isSameMedia(musicItem, this.currentMusic);\n    }\n\n}\n\n\nexport default new TrackPlayer();\n"
  },
  {
    "path": "src/renderer/core/track-player/store.ts",
    "content": "import Store from \"@/common/store\";\nimport { ICurrentLyric } from \"@renderer/core/track-player/enum\";\nimport { PlayerState, RepeatMode } from \"@/common/constant\";\n\nconst initProgress = {\n    currentTime: 0,\n    duration: Infinity,\n};\n\n\n/** 音乐队列 */\nconst musicQueueStore = new Store<IMusic.IMusicItem[]>([]);\n\n/** 当前播放 */\nconst currentMusicStore = new Store<IMusic.IMusicItem | null>(null);\n\n/** 当前歌词解析器 */\nconst currentLyricStore = new Store<ICurrentLyric | null>(null);\n\n/** 播放模式 */\nconst repeatModeStore = new Store(RepeatMode.Queue);\n\n/** 进度 */\nconst progressStore = new Store(initProgress);\n\n/** 播放状态 */\nconst playerStateStore = new Store(PlayerState.None);\n\n/** 音量 */\nconst currentVolumeStore = new Store(1);\n\n/** 速度 */\nconst currentSpeedStore = new Store(1);\n\n/** 音质 */\nconst currentQualityStore = new Store<IMusic.IQualityKey>(\"standard\");\n\n\nfunction resetProgress() {\n    progressStore.setValue(initProgress);\n}\n\nconst _trackPlayerStore = {\n    musicQueueStore,\n    currentMusicStore,\n    currentLyricStore,\n    repeatModeStore,\n    progressStore,\n    playerStateStore,\n    currentVolumeStore,\n    currentSpeedStore,\n    currentQualityStore,\n    resetProgress,\n};\n\nexport default _trackPlayerStore;\n"
  },
  {
    "path": "src/renderer/document/bootstrap.ts",
    "content": "import { localPluginHash, PlayerState, RepeatMode, supportLocalMediaType } from \"@/common/constant\";\nimport MusicSheet from \"../core/music-sheet\";\nimport trackPlayer from \"../core/track-player\";\nimport localMusic from \"../core/local-music\";\nimport { setAutoFreeze } from \"immer\";\nimport Downloader from \"../core/downloader\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { setupI18n } from \"@/shared/i18n/renderer\";\nimport ThemePack from \"@/shared/themepack/renderer\";\nimport { addToRecentlyPlaylist, setupRecentlyPlaylist } from \"../core/recently-playlist\";\nimport ServiceManager from \"@shared/service-manager/renderer\";\nimport { CurrentTime, PlayerEvents } from \"@renderer/core/track-player/enum\";\nimport { appWindowUtil, fsUtil } from \"@shared/utils/renderer\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\nimport messageBus from \"@shared/message-bus/renderer/main\";\nimport throttle from \"lodash.throttle\";\nimport { IAppState } from \"@shared/message-bus/type\";\nimport MusicDetail from \"@renderer/components/MusicDetail\";\nimport shortCut from \"@shared/short-cut/renderer\";\n\n\nsetAutoFreeze(false);\n\nexport default async function () {\n    await Promise.all([\n        AppConfig.setup(),\n        PluginManager.setup(),\n    ]);\n    await Promise.all([\n        MusicSheet.frontend.setupMusicSheets(),\n        trackPlayer.setup(),\n    ]);\n    await setupI18n();\n    shortCut.setup();\n    dropHandler();\n    clearDefaultBehavior();\n    setupCommandAndEvents();\n    setupDeviceChange();\n    localMusic.setupLocalMusic();\n    await Downloader.setupDownloader();\n    setupRecentlyPlaylist();\n    // 本地服务\n    ServiceManager.setup();\n\n    // 自动更新插件\n    if (AppConfig.getConfig(\"plugin.autoUpdatePlugin\")) {\n        const lastUpdated = +(localStorage.getItem(\"pluginLastupdatedTime\") || 0);\n        const now = Date.now();\n        if (Math.abs(now - lastUpdated) > 86400000) {\n            localStorage.setItem(\"pluginLastupdatedTime\", `${now}`);\n            PluginManager.updateAllPlugins();\n        }\n    }\n\n}\n\nfunction dropHandler() {\n    document.addEventListener(\"drop\", async (event) => {\n        event.preventDefault();\n        event.stopPropagation();\n        console.log(event);\n\n        const validMusicList: IMusic.IMusicItem[] = [];\n        for (const f of event.dataTransfer.files) {\n            if (f.type === \"\" && (await fsUtil.isFolder(f.path))) {\n                validMusicList.push(\n                    ...(await PluginManager.callPluginDelegateMethod(\n                        {\n                            hash: localPluginHash,\n                        },\n                        \"importMusicSheet\",\n                        f.path,\n                    )),\n                );\n            } else if (\n                supportLocalMediaType.some((postfix) => f.path.endsWith(postfix))\n            ) {\n                validMusicList.push(\n                    await PluginManager.callPluginDelegateMethod(\n                        {\n                            hash: localPluginHash,\n                        },\n                        \"importMusicItem\",\n                        f.path,\n                    ),\n                );\n            } else if (f.path.endsWith(\".mftheme\")) {\n                // 主题包\n                const themeConfig = await ThemePack.installThemePack(f.path);\n                if (themeConfig) {\n                    await ThemePack.selectTheme(themeConfig);\n                }\n            }\n        }\n        if (validMusicList.length) {\n            trackPlayer.playMusicWithReplaceQueue(validMusicList);\n        }\n    });\n\n    document.addEventListener(\"dragover\", (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n    });\n}\n\nfunction clearDefaultBehavior() {\n    const killSpaceBar = function (evt: any) {\n        // https://greasyfork.org/en/scripts/25035-disable-space-bar-scrolling/code\n        const target = evt.target || {},\n            isInput =\n                \"INPUT\" == target.tagName ||\n                \"TEXTAREA\" == target.tagName ||\n                \"SELECT\" == target.tagName ||\n                \"EMBED\" == target.tagName;\n\n        // if we're an input or not a real target exit\n        if (isInput || !target.tagName) return;\n\n        // if we're a fake input like the comments exit\n        if (\n            target &&\n            target.getAttribute &&\n            target.getAttribute(\"role\") === \"textbox\"\n        )\n            return;\n\n        // ignore the space\n        if (evt.keyCode === 32) {\n            evt.preventDefault();\n        }\n    };\n\n    document.addEventListener(\"keydown\", killSpaceBar, false);\n}\n\n\n/** 设置事件 */\nfunction setupCommandAndEvents() {\n    messageBus.onCommand(\"SkipToNext\", () => {\n        trackPlayer.skipToNext();\n    });\n    messageBus.onCommand(\"SkipToPrevious\", () => {\n        trackPlayer.skipToPrev();\n    });\n    messageBus.onCommand(\"TogglePlayerState\", () => {\n        if (trackPlayer.playerState === PlayerState.Playing) {\n            trackPlayer.pause();\n        } else {\n            trackPlayer.resume();\n        }\n    });\n    messageBus.onCommand(\"SetRepeatMode\", (mode) => {\n        trackPlayer.setRepeatMode(mode);\n    });\n    messageBus.onCommand(\"VolumeUp\", (val = 0.04) => {\n        trackPlayer.setVolume(Math.min(1, trackPlayer.volume + val));\n    });\n\n    messageBus.onCommand(\"VolumeDown\", (val = 0.04) => {\n        trackPlayer.setVolume(Math.max(0, trackPlayer.volume - val));\n    });\n\n    messageBus.onCommand(\"ToggleFavorite\", async (item) => {\n        const realItem = item || trackPlayer.currentMusic;\n        if (MusicSheet.frontend.isFavoriteMusic(realItem)) {\n            MusicSheet.frontend.removeMusicFromFavorite(realItem);\n        } else {\n            MusicSheet.frontend.addMusicToFavorite(realItem);\n        }\n    });\n\n    messageBus.onCommand(\"ToggleDesktopLyric\", () => {\n        const enableDesktopLyric = AppConfig.getConfig(\"lyric.enableDesktopLyric\");\n        appWindowUtil.setLyricWindow(!enableDesktopLyric);\n        AppConfig.setConfig({\n            \"lyric.enableDesktopLyric\": !enableDesktopLyric,\n        });\n    });\n\n    messageBus.onCommand(\"OpenMusicDetailPage\", () => {\n        MusicDetail.show();\n    });\n\n    messageBus.onCommand(\"ToggleMainWindowVisible\", () => {\n        appWindowUtil.toggleMainWindowVisible();\n    });\n\n\n    const sendAppStateTo = (from: \"main\" | number) => {\n        const appState: IAppState = {\n            repeatMode: trackPlayer.repeatMode || RepeatMode.Queue,\n            playerState: trackPlayer.playerState || PlayerState.None,\n            musicItem: trackPlayer.currentMusicBasicInfo || null,\n            lyricText: trackPlayer.lyric?.currentLrc?.lrc || null,\n            parsedLrc: trackPlayer.lyric?.currentLrc || null,\n            fullLyric: trackPlayer.lyric?.parser?.getLyricItems() || [],\n            progress: trackPlayer.progress?.currentTime || 0,\n            duration: trackPlayer.progress?.duration || 0,\n        };\n\n        messageBus.syncAppState(appState, from);\n    };\n\n    messageBus.onCommand(\"SyncAppState\", (_, from) => {\n        sendAppStateTo(from);\n    });\n    sendAppStateTo(\"main\");\n\n    // 状态同步\n    trackPlayer.on(PlayerEvents.StateChanged, state => {\n        messageBus.syncAppState({\n            playerState: state,\n        });\n    });\n\n    trackPlayer.on(PlayerEvents.RepeatModeChanged, mode => {\n        messageBus.syncAppState({\n            repeatMode: mode,\n        });\n    });\n\n    trackPlayer.on(PlayerEvents.CurrentLyricChanged, lyric => {\n        messageBus.syncAppState({\n            lyricText: lyric.lrc,\n            parsedLrc: lyric,\n        });\n    });\n\n    trackPlayer.on(PlayerEvents.LyricChanged, lyric => {\n        messageBus.syncAppState({\n            fullLyric: lyric?.getLyricItems?.() || [],\n        });\n    });\n\n    const progressChangedHandler = throttle((currentTime: CurrentTime) => {\n        messageBus.syncAppState({\n            progress: currentTime?.currentTime || 0,\n            duration: currentTime.duration || 0,\n        });\n    }, 800);\n\n    trackPlayer.on(PlayerEvents.ProgressChanged, progressChangedHandler);\n\n    // 最近播放\n    trackPlayer.on(PlayerEvents.MusicChanged, (musicItem) => {\n        messageBus.syncAppState({\n            musicItem,\n            lyricText: null,\n            fullLyric: [],\n            parsedLrc: null,\n            progress: 0,\n            duration: 0,\n        });\n        addToRecentlyPlaylist(musicItem);\n    });\n}\n\nasync function setupDeviceChange() {\n    const getAudioDevices = async () =>\n        await navigator.mediaDevices.enumerateDevices().catch(() => []);\n    let devices = (await getAudioDevices()) || [];\n\n    navigator.mediaDevices.ondevicechange = async (evt) => {\n        const newDevices = await getAudioDevices();\n        if (\n            newDevices.length < devices.length &&\n            AppConfig.getConfig(\"playMusic.whenDeviceRemoved\") === \"pause\"\n        ) {\n            trackPlayer.pause();\n        }\n        devices = newDevices;\n    };\n}\n"
  },
  {
    "path": "src/renderer/document/fallback.tsx",
    "content": "import trackPlayer from \"../core/track-player\";\nimport \"./styles/fallback.scss\";\n\ninterface IProps {\n    error: Error,\n    resetErrorBoundary: (...args: any[]) => void,\n}\n\nexport default function Fallback(props: IProps) {\n    const { error, resetErrorBoundary } = props;\n\n    return (\n        <div className=\"fallback-container\" role=\"alert\">\n            <div className=\"fallback-content\">\n                <div className=\"fallback-title\">\n                    出现问题啦...\n                </div>\n\n                <div className=\"fallback-actions\">\n                    <button\n                        className=\"reset-button\"\n                        onClick={resetErrorBoundary}\n                    >\n                        重置配置项和播放器状态\n                    </button>\n                </div>\n\n                <div className=\"fallback-description\">\n                    请点击上方【重置配置项】按钮尝试修复，如果还有问题请将错误信息反馈到 GitHub 或发送到公众号【一只猫头猫】\n                </div>\n\n                <div className=\"fallback-section\">\n                    <div className=\"section-title\">歌曲信息</div>\n                    <div className=\"section-content\">\n                        <pre className=\"music-info\">\n                            {JSON.stringify(trackPlayer.currentMusic, null, 2)}\n                        </pre>\n                    </div>\n                </div>\n\n                <div className=\"fallback-section\">\n                    <div className=\"section-title\">错误信息</div>\n                    <div className=\"section-content\">\n                        <pre className=\"error-message\">\n                            {error.message}\n                        </pre>\n                        {error.stack && (\n                            <pre className=\"error-message\" style={{ marginTop: 8 }}>\n                                {error.stack}\n                            </pre>\n                        )}\n                    </div>\n                </div>\n\n\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/document/index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Music Free</title>\n    <meta name=\"referrer\" content=\"no-referrer\"/>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <style id=\"themepack-node\"></style>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "src/renderer/document/index.tsx",
    "content": "import ReactDOM from \"react-dom/client\";\nimport App from \"../app\";\nimport \"animate.css\";\nimport ModalComponent from \"../components/Modal\";\nimport bootstrap from \"./bootstrap\";\nimport { HashRouter, Route, Routes } from \"react-router-dom\";\nimport MainPage from \"../pages/main-page\";\nimport { ContextMenuComponent } from \"../components/ContextMenu\";\nimport { ToastContainer } from \"react-toastify\";\n\nimport \"rc-slider/assets/index.css\";\nimport \"react-toastify/dist/ReactToastify.css\";\nimport \"./styles/index.scss\"; // 全局样式\nimport { toastDuration } from \"@/common/constant\";\nimport useBootstrap from \"./useBootstrap\";\nimport logger from \"@shared/logger/renderer\";\nimport { ErrorBoundary } from \"react-error-boundary\";\nimport Fallback from \"@renderer/document/fallback\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport trackPlayer from \"../core/track-player\";\n\nlogger.logPerf(\"Create Bundle\");\nbootstrap().then(() => {\n    logger.logPerf(\"Bundle Bootstrap Ready\");\n    ReactDOM.createRoot(document.getElementById(\"root\")).render(<ErrorBoundary\n        FallbackComponent={Fallback} onReset={() => {\n            // 删除软件配置\n            AppConfig.reset();\n            trackPlayer.reset();\n        }}><Root></Root></ErrorBoundary>);\n});\n\nfunction Root() {\n    return (\n        <>\n            <HashRouter>\n                <BootstrapComponent></BootstrapComponent>\n                <Routes>\n                    <Route path=\"/\" element={<App></App>}>\n                        <Route path=\"main/*\" element={<MainPage></MainPage>}></Route>\n                        <Route path=\"*\" element={<MainPage></MainPage>}></Route>\n                    </Route>\n                </Routes>\n                <ModalComponent></ModalComponent>\n            </HashRouter>\n            <ContextMenuComponent></ContextMenuComponent>\n            <ToastContainer\n                draggable={false}\n                closeOnClick={false}\n                limit={5}\n                pauseOnFocusLoss={false}\n                hideProgressBar\n                autoClose={toastDuration.short}\n                newestOnTop\n            ></ToastContainer>\n        </>\n    );\n}\n\nfunction BootstrapComponent(): null {\n    useBootstrap();\n\n    return null;\n}\n"
  },
  {
    "path": "src/renderer/document/styles/base.scss",
    "content": "// 基础样式重置和全局样式\nhtml,\nbody {\n    margin: 0;\n    width: 100vw;\n    height: 100vh;\n    overflow: hidden;\n    background-color: var(--backgroundColor);\n}\n\n// 链接样式\na {\n    color: var(--linkColor);\n}\n\n// 美化后的滚动条样式\n::-webkit-scrollbar {\n    width: var(--scrollbarWidth, 8px);\n    height: 8px;\n}\n\n::-webkit-scrollbar-track {\n    background: transparent;\n    border-radius: 6px;\n}\n\n::-webkit-scrollbar-thumb {\n    background: linear-gradient(135deg,\n            rgba(0, 0, 0, 0.15) 0%,\n            rgba(0, 0, 0, 0.25) 100%);\n    border-radius: 6px;\n    border: 2px solid transparent;\n    background-clip: content-box;\n    transition: all 0.2s ease;\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background: linear-gradient(135deg,\n            rgba(0, 0, 0, 0.25) 0%,\n            rgba(0, 0, 0, 0.35) 100%);\n    background-clip: content-box;\n}\n\n::-webkit-scrollbar-thumb:active {\n    background: linear-gradient(135deg,\n            rgba(0, 0, 0, 0.35) 0%,\n            rgba(0, 0, 0, 0.45) 100%);\n    background-clip: content-box;\n}\n\n::-webkit-scrollbar-corner {\n    background: transparent;\n}\n\n// 在深色主题下的滚动条样式优化\n@media (prefers-color-scheme: dark) {\n    ::-webkit-scrollbar-thumb {\n        background: linear-gradient(135deg,\n                rgba(255, 255, 255, 0.15) 0%,\n                rgba(255, 255, 255, 0.25) 100%);\n        background-clip: content-box;\n    }\n\n    ::-webkit-scrollbar-thumb:hover {\n        background: linear-gradient(135deg,\n                rgba(255, 255, 255, 0.25) 0%,\n                rgba(255, 255, 255, 0.35) 100%);\n        background-clip: content-box;\n    }\n\n    ::-webkit-scrollbar-thumb:active {\n        background: linear-gradient(135deg,\n                rgba(255, 255, 255, 0.35) 0%,\n                rgba(255, 255, 255, 0.45) 100%);\n        background-clip: content-box;\n    }\n}"
  },
  {
    "path": "src/renderer/document/styles/components.scss",
    "content": "// 表单组件样式\ninput {\n    outline: none;\n    font-size: 1rem;\n    padding: 0.4rem 0.6rem;\n    border-radius: 6px;\n    border: 1px solid var(--dividerColor);\n    box-sizing: border-box;\n    background: var(--placeholderColor);\n    color: var(--textColor);\n    transition: all 0.2s ease;\n\n    &:focus {\n        border-color: var(--primaryColor);\n        background: var(--backgroundColor);\n        box-shadow: 0 0 0 2px rgba(241, 125, 52, 0.1);\n    }\n\n    &::placeholder {\n        opacity: 0.6;\n        color: var(--textColor);\n    }\n}\n\n// 按钮组件样式\ndiv[role=\"button\"] {\n    cursor: pointer;\n    user-select: none;\n    transition: all 0.2s ease;\n\n    &[data-disabled]:not([data-disabled=\"false\"]) {\n        cursor: default;\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    &[data-type=\"primaryButton\"] {\n        background-color: var(--primaryColor);\n        font-size: 1em;\n        padding: 0.6em 1em;\n        border-radius: 8px;\n        color: white;\n        width: fit-content;\n        line-height: 1em;\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        box-shadow: 0 2px 4px rgba(241, 125, 52, 0.2);\n\n        &:hover {\n            background-color: color-mix(in srgb, var(--primaryColor) 90%, black);\n            box-shadow: 0 4px 8px rgba(241, 125, 52, 0.3);\n            transform: translateY(-1px);\n        }\n\n        &:active {\n            transform: translateY(0);\n            box-shadow: 0 2px 4px rgba(241, 125, 52, 0.2);\n        }\n    }\n\n    &[data-type=\"normalButton\"] {\n        font-size: 1em;\n        padding: 0.6em 1em;\n        border-radius: 8px;\n        color: var(--textColor);\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n        background-color: color-mix(in srgb, currentColor 8%, transparent);\n        display: flex;\n        justify-content: center;\n        align-items: center;\n\n        &:hover {\n            background-color: color-mix(in srgb, currentColor 15%, transparent);\n            transform: translateY(-1px);\n        }\n\n        &:active {\n            transform: translateY(0);\n            background-color: color-mix(in srgb, currentColor 20%, transparent);\n        }\n    }\n\n    &[data-type=\"dangerButton\"] {\n        font-size: 1em;\n        padding: 0.6em 1em;\n        border-radius: 8px;\n        color: var(--dangerColor);\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n        display: flex;\n        justify-content: center;\n        align-items: center;\n        background-color: color-mix(in srgb, var(--dangerColor) 8%, transparent);\n\n        &:hover {\n            background-color: color-mix(in srgb, var(--dangerColor) 15%, transparent);\n            transform: translateY(-1px);\n        }\n\n        &:active {\n            transform: translateY(0);\n            background-color: color-mix(in srgb, var(--dangerColor) 20%, transparent);\n        }\n\n        &[data-fill=\"true\"] {\n            color: white;\n            background: var(--dangerColor);\n            box-shadow: 0 2px 4px rgba(252, 95, 95, 0.2);\n\n            &:hover {\n                background: color-mix(in srgb, var(--dangerColor) 90%, black);\n                box-shadow: 0 4px 8px rgba(252, 95, 95, 0.3);\n            }\n\n            &:active {\n                box-shadow: 0 2px 4px rgba(252, 95, 95, 0.2);\n            }\n        }\n    }\n}"
  },
  {
    "path": "src/renderer/document/styles/fallback.scss",
    "content": "// 错误边界页面样式\n\n.fallback-container {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: flex-start;\n    min-height: 100vh;\n    max-height: 100vh;\n    padding: 24px;\n    background-color: var(--backgroundColor);\n    color: var(--textColor);\n    font-size: 14px;\n    line-height: 1.6;\n    overflow-y: auto;\n\n    // 为整个容器添加滚动条样式\n    &::-webkit-scrollbar {\n        width: var(--scrollbarWidth);\n    }\n\n    &::-webkit-scrollbar-track {\n        background: transparent;\n    }\n\n    &::-webkit-scrollbar-thumb {\n        background: var(--dividerColor);\n        border-radius: 4px;\n    }\n\n    &::-webkit-scrollbar-thumb:hover {\n        background: var(--listActiveColor);\n    }\n\n    .fallback-content {\n        max-width: 800px;\n        width: 100%;\n        background: var(--backgroundColor);\n        border-radius: 12px;\n        padding: 32px;\n        box-shadow: 0 4px 20px var(--shadowColor);\n        border: 1px solid var(--dividerColor);\n        margin: auto;\n        flex-shrink: 0;\n    }\n\n    .fallback-title {\n        font-size: 24px;\n        font-weight: 600;\n        color: var(--dangerColor);\n        margin-bottom: 16px;\n        text-align: center;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        gap: 8px;\n\n        &::before {\n            content: \"⚠️\";\n            font-size: 28px;\n        }\n    }\n\n    .fallback-description {\n        color: var(--textColor);\n        margin-bottom: 24px;\n        text-align: center;\n        opacity: 0.8;\n        line-height: 1.8;\n    }\n\n    .fallback-section {\n        margin-bottom: 24px;\n\n        .section-title {\n            font-size: 16px;\n            font-weight: 600;\n            color: var(--textColor);\n            margin-bottom: 12px;\n            padding-bottom: 8px;\n            border-bottom: 2px solid var(--dividerColor);\n        }\n\n        .section-content {\n            background: var(--placeholderColor);\n            border-radius: 8px;\n            padding: 16px;\n            border: 1px solid var(--dividerColor);\n            max-height: 300px;\n            overflow-y: auto;\n\n            &::-webkit-scrollbar {\n                width: var(--scrollbarWidth);\n            }\n\n            &::-webkit-scrollbar-track {\n                background: transparent;\n            }\n\n            &::-webkit-scrollbar-thumb {\n                background: var(--dividerColor);\n                border-radius: 4px;\n            }\n\n            &::-webkit-scrollbar-thumb:hover {\n                background: var(--listActiveColor);\n            }\n\n            pre {\n                margin: 0;\n                font-family: 'Consolas', 'Monaco', 'Courier New', monospace;\n                font-size: 12px;\n                line-height: 1.5;\n                white-space: pre-wrap;\n                word-break: break-word;\n                overflow-wrap: break-word;\n\n                &::-webkit-scrollbar {\n                    width: var(--scrollbarWidth);\n                }\n\n                &::-webkit-scrollbar-track {\n                    background: transparent;\n                }\n\n                &::-webkit-scrollbar-thumb {\n                    background: var(--dividerColor);\n                    border-radius: 4px;\n                }\n\n                &::-webkit-scrollbar-thumb:hover {\n                    background: var(--listActiveColor);\n                }\n\n                &.error-message {\n                    color: var(--dangerColor);\n                    background: rgba(252, 95, 95, 0.1);\n                    padding: 12px;\n                    border-radius: 6px;\n                    border-left: 4px solid var(--dangerColor);\n                    margin-bottom: 8px;\n                }\n\n                &.music-info {\n                    color: var(--infoColor);\n                    background: rgba(10, 149, 200, 0.1);\n                    padding: 12px;\n                    border-radius: 6px;\n                    border-left: 4px solid var(--infoColor);\n                }\n            }\n        }\n    }\n\n    .fallback-actions {\n        display: flex;\n        justify-content: center;\n        margin-top: 32px;\n        margin-bottom: 32px;\n    }\n\n    .reset-button {\n        background: linear-gradient(135deg, var(--dangerColor), #ff4757);\n        color: white;\n        border: none;\n        border-radius: 8px;\n        padding: 12px 24px;\n        font-size: 14px;\n        font-weight: 600;\n        cursor: pointer;\n        transition: all var(--animate-duration) ease;\n        box-shadow: 0 4px 12px rgba(252, 95, 95, 0.3);\n        display: flex;\n        align-items: center;\n        gap: 8px;\n\n        &:hover {\n            transform: translateY(-2px);\n            box-shadow: 0 6px 20px rgba(252, 95, 95, 0.4);\n            background: linear-gradient(135deg, #ff4757, var(--dangerColor));\n        }\n\n        &:active {\n            transform: translateY(0);\n            box-shadow: 0 2px 8px rgba(252, 95, 95, 0.3);\n        }\n\n        &::before {\n            content: \"🔄\";\n            font-size: 16px;\n        }\n    }\n\n    // 响应式设计\n    @media (max-width: 768px) {\n        padding: 16px;\n\n        .fallback-content {\n            padding: 24px;\n        }\n\n        .fallback-title {\n            font-size: 20px;\n        }\n\n        .section-content {\n            max-height: 250px;\n\n            pre {\n                font-size: 11px;\n            }\n        }\n    }\n\n    // 深色主题适配\n    @media (prefers-color-scheme: dark) {\n        .fallback-content {\n            background: rgba(255, 255, 255, 0.05);\n        }\n\n        .section-content {\n            background: rgba(255, 255, 255, 0.03);\n        }\n    }\n}"
  },
  {
    "path": "src/renderer/document/styles/index.scss",
    "content": "// 主样式文件 - 导入所有样式模块\n@forward './variables.scss';\n@forward './base.scss';\n@forward './utilities.scss';\n@forward './components.scss';\n@forward './tables.scss';\n@forward './fallback.scss';"
  },
  {
    "path": "src/renderer/document/styles/tables.scss",
    "content": "// 表格组件样式\ntable {\n    width: 100%;\n    table-layout: fixed;\n    user-select: none;\n    border-collapse: collapse;\n    $row-height: 2.6rem;\n\n    & thead {\n        & tr {\n            height: $row-height;\n\n            & th {\n                text-align: left;\n                border-bottom: 1px solid var(--dividerColor);\n                font-weight: 600;\n                color: var(--textColor);\n                padding: 0 12px;\n\n                &:first-child {\n                    text-align: center;\n                }\n            }\n        }\n    }\n\n    & tbody {\n        & tr {\n            height: $row-height;\n            transition: background-color 0.15s ease;\n\n            & td {\n                text-align: left;\n                padding: 0 12px;\n                border-bottom: 1px solid transparent;\n\n                &:first-child {\n                    text-align: center;\n                }\n            }\n\n            &:nth-child(even) {\n                background-color: var(--listEvenColor);\n            }\n\n            &:hover {\n                background: var(--listHoverColor);\n            }\n\n            &[data-active=\"true\"] {\n                background: var(--listActiveColor);\n\n            }\n        }\n    }\n}\n\n// Toast组件位置调整\n.Toastify__toast-container--top-right {\n    top: calc(var(--appHeaderHeight) + 1rem);\n}\n\n// iframe样式\niframe {\n    width: 100%;\n    height: 100%;\n    border: none;\n    position: absolute;\n    top: 0;\n    left: 0;\n    overflow: hidden;\n    pointer-events: none;\n}"
  },
  {
    "path": "src/renderer/document/styles/utilities.scss",
    "content": "// 工具类样式\n.blur10 {\n    backdrop-filter: blur(10px);\n}\n\n.divider {\n    width: 100%;\n    height: 1px;\n    background-color: var(--dividerColor);\n    margin-top: 12px;\n    margin-bottom: 12px;\n}\n\n.shadow {\n    box-shadow: var(--shadow, var(--shadowColor) 2px 2px 8px);\n}\n\n.background-color {\n    background: var(--backgroundColor);\n}\n\n.backdrop-color {\n    background: var(--backdropColor, var(--backgroundColor));\n}\n\n.opacity-button {\n    opacity: 0.6;\n    transition: opacity 0.2s ease;\n\n    &:hover {\n        opacity: 1;\n    }\n}\n\n.highlight {\n    color: var(--primaryColor) !important;\n}\n\n// 列表行为样式\n.list-behavior {\n    cursor: pointer;\n    transition: background-color 0.15s ease;\n\n    &:hover {\n        background-color: var(--listHoverColor);\n    }\n\n    &:active {\n        background-color: var(--listActiveColor);\n    }\n\n    &[data-selected=\"true\"] {\n        background-color: var(--listActiveColor);\n    }\n}"
  },
  {
    "path": "src/renderer/document/styles/variables.scss",
    "content": "// 全局变量定义模块\n\n// ===========================\n// 1. 主题色彩变量 (Theme Colors)\n// ===========================\n$primary-color: #f17d34;\n$background-color: #fdfdfd;\n$text-color: #333333;\n$link-color: #0c66fc;\n\n// ===========================\n// 2. 状态色彩变量 (Status Colors)\n// ===========================\n$success-color: #08a34c;\n$danger-color: #fc5f5f;\n$info-color: #0a95c8;\n\n// ===========================\n// 3. 交互色彩变量 (Interactive Colors)\n// ===========================\n$divider-color: rgba(0, 0, 0, 0.1);\n$list-even-color: rgba(0, 0, 0, 0.05);\n$list-hover-color: rgba(0, 0, 0, 0.05);\n$list-active-color: rgba(0, 0, 0, 0.1);\n$mask-color: rgba(51, 51, 51, 0.5);\n$shadow-color: rgba(0, 0, 0, 0.2);\n$placeholder-color: #f4f4f4;\n\n// ===========================\n// 4. 尺寸变量 (Dimensions)\n// ===========================\n$app-header-height: 54px;\n$app-music-bar-height: 64px;\n$scrollbar-width: 12px;\n$font-size: 13px;\n\n// ===========================\n// 5. 动效变量 (Animation)\n// ===========================\n$animate-duration: 300ms;\n\n// CSS自定义属性定义（供主题系统使用）\n:root {\n    // ===========================\n    // 1. 主题色彩 (Theme Colors)\n    // ===========================\n    --primaryColor: #{$primary-color};\n    --backgroundColor: #{$background-color};\n    --textColor: #{$text-color};\n    --linkColor: #{$link-color};\n\n    // ===========================\n    // 2. 状态色彩 (Status Colors)\n    // ===========================\n    --successColor: #{$success-color};\n    --dangerColor: #{$danger-color};\n    --infoColor: #{$info-color};\n\n    // ===========================\n    // 3. 文本色彩 (Text Colors)\n    // ===========================\n    --headerTextColor: white;\n\n    // ===========================\n    // 4. 交互色彩 (Interactive Colors)\n    // ===========================\n    --dividerColor: #{$divider-color};\n    --listEvenColor: #{$list-even-color};\n    --listHoverColor: #{$list-hover-color};\n    --listActiveColor: #{$list-active-color};\n    --maskColor: #{$mask-color};\n    --shadowColor: #{$shadow-color};\n    --placeholderColor: #{$placeholder-color};\n\n    // ===========================\n    // 5. 布局尺寸 (Layout Dimensions)\n    // ===========================\n    --appHeaderHeight: #{$app-header-height};\n    --appMusicBarHeight: #{$app-music-bar-height};\n\n    // ===========================\n    // 6. 组件尺寸 (Component Dimensions)\n    // ===========================\n    --scrollbarWidth: #{$scrollbar-width};\n    --fontSize: #{$font-size};\n\n    // ===========================\n    // 7. 动效变量 (Animation)\n    // ===========================\n    --animate-duration: #{$animate-duration} !important;\n\n    // ===========================\n    // 8. 兼容性变量 (Deprecated)\n    // ===========================\n    --scrollbar-width: var(--scrollbarWidth); // @deprecated 使用 --scrollbarWidth\n}"
  },
  {
    "path": "src/renderer/document/useBootstrap.ts",
    "content": "import { useEffect, useLayoutEffect } from \"react\";\nimport { useNavigate } from \"react-router-dom\";\nimport checkUpdate from \"../utils/check-update\";\nimport Themepack from \"@/shared/themepack/renderer\";\nimport logger from \"@shared/logger/renderer\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport messageBus from \"@shared/message-bus/renderer/main\";\n\nexport default function useBootstrap() {\n    const navigate = useNavigate();\n\n    useLayoutEffect(() => {\n        Themepack.setupThemePacks();\n    }, []);\n\n    useEffect(() => {\n        messageBus.onCommand(\"Navigate\", (route) => {\n            navigate(route);\n        });\n\n        if (AppConfig.getConfig(\"normal.checkUpdate\")) {\n            checkUpdate();\n        }\n        logger.logPerf(\"Bundle First Screen\");\n    }, []);\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/index.scss",
    "content": ".side-bar-container {\n    width: 220px;\n    height: 100%;\n    flex-shrink: 0;\n    flex-grow: 0;\n    overflow-y: auto;\n    box-sizing: border-box;\n    padding: 12px 0;\n    border-right:1px solid var(--dividerColor);\n    position: relative;\n}"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/index.tsx",
    "content": "import ListItem from \"./widgets/ListItem\";\nimport \"./index.scss\";\nimport MySheets from \"./widgets/MySheets\";\nimport { useMatch, useNavigate } from \"react-router\";\nimport StarredSheets from \"./widgets/StarredSheets\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function () {\n    const navigate = useNavigate();\n    const routePathMatch = useMatch(\"/main/:routePath\");\n    const { t } = useTranslation();\n\n    const options = [\n        {\n            iconName: \"trophy\",\n            title: t(\"side_bar.toplist\"),\n            route: \"toplist\",\n        },\n        {\n            iconName: \"fire\",\n            title: t(\"side_bar.recommend_sheets\"),\n            route: \"recommend-sheets\",\n        },\n        {\n            iconName: \"array-download-tray\",\n            title: t(\"side_bar.download_management\"),\n            route: \"download\",\n        },\n        {\n            iconName: \"folder-open\",\n            title: t(\"side_bar.local_music\"),\n            route: \"local-music\",\n        },\n        {\n            iconName: \"code-bracket-square\",\n            title: t(\"side_bar.plugin_management\"),\n            route: \"plugin-manager-view\",\n        },\n        {\n            iconName: \"clock\",\n            title: t(\"side_bar.recently_play\"),\n            route: \"recently_play\",\n        },\n    ] as const;\n\n    return (\n        <div className=\"side-bar-container\">\n            {options.map((item) => (\n                <ListItem\n                    key={item.route}\n                    iconName={item.iconName}\n                    title={item.title}\n                    selected={routePathMatch?.params?.routePath === item.route}\n                    onClick={() => {\n                        navigate(`/main/${item.route}`);\n                    }}\n                ></ListItem>\n            ))}\n            <MySheets></MySheets>\n            <StarredSheets></StarredSheets>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/ListItem/index.scss",
    "content": ".side-bar--list-item-container {\n  $height: 3rem;\n  height: $height;\n  font-size: 1rem;\n  width: 100%;\n  position: relative;\n  display: flex;\n  align-items: center;\n  box-sizing: border-box;\n  padding-left: 1rem;\n  user-select: none;\n  cursor: pointer;\n  color: var(--textColor);\n  transition: all linear 100ms;\n\n  & svg {\n    width: 1.6rem;\n    height: 1.6rem;\n    margin-right: 0.5rem;\n    flex-shrink: 0;\n  }\n\n  & span {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    padding-right: 0.5rem;\n  }\n\n  &:hover {\n    background: var(--listHoverColor);\n  }\n\n  &[data-selected=\"true\"] {\n    color: var(--primaryColor);\n    background: var(--listActiveColor);\n\n    &::before {\n      content: \"\";\n      position: absolute;\n      width: 4px;\n      left: 0;\n      top: 0;\n      height: $height;\n      background-color: var(--primaryColor);\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/ListItem/index.tsx",
    "content": "import SvgAsset, { SvgAssetIconNames } from \"@/renderer/components/SvgAsset\";\nimport \"./index.scss\";\n\ninterface IProps {\n    selected?: boolean;\n    onClick?: () => void;\n    onContextMenu?: (...args: any) => void;\n    iconName?: SvgAssetIconNames;\n    title?: string;\n}\n\nexport default function ListItem(props: IProps) {\n    const { selected, onClick, iconName, title, onContextMenu } = props ?? {};\n    return (\n        <div\n            onClick={onClick}\n            onContextMenu={onContextMenu}\n            title={title}\n            role=\"button\"\n            className=\"side-bar--list-item-container\"\n            data-selected={selected}\n        >\n            {iconName ? <SvgAsset iconName={iconName}></SvgAsset> : null}\n            <span>{title ?? \"\"}</span>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/MySheets/index.scss",
    "content": ".side-bar-container--my-sheets {\n  & .divider {\n    margin-top: 0.5rem;\n    width: 100%;\n    height: 1px;\n    background-color: var(--dividerColor);\n  }\n  & .title {\n    height: 3rem;\n    display: flex;\n    align-items: center;\n    padding-left: 1rem;\n    padding-right: 0.5rem;\n    opacity: 0.7;\n    user-select: none;\n\n    $tag-size: 4px;\n    & .my-sheets {\n      display: flex;\n      align-items: center;\n      flex: 1;\n\n      &::after {\n        content: \"\";\n        width: 0;\n        height: 0;\n        margin-left: 0.5rem;\n        border: $tag-size solid transparent;\n        border-left-color: currentColor;\n        transform-origin: left center;\n        transition: transform linear 100ms;\n      }\n    }\n\n    &[data-headlessui-state=\"open\"] {\n      & .my-sheets {\n        &::after {\n          transform: rotate(90deg);\n        }\n      }\n    }\n\n    & .option-btn {\n      $size: 18px;\n      width: $size;\n      height: $size;\n      margin-left: 6px;\n\n      & svg {\n        width: $size;\n        height: $size;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/MySheets/index.tsx",
    "content": "import \"./index.scss\";\nimport ListItem from \"../ListItem\";\nimport { useMatch, useNavigate } from \"react-router-dom\";\nimport { Disclosure } from \"@headlessui/react\";\nimport MusicSheet, { defaultSheet } from \"@/renderer/core/music-sheet\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { hideModal, showModal } from \"@/renderer/components/Modal\";\nimport { localPluginName } from \"@/common/constant\";\nimport { showContextMenu } from \"@/renderer/components/ContextMenu\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSupportedPlugin } from \"@shared/plugin-manager/renderer\";\n\n\nexport default function MySheets() {\n    const sheetIdMatch = useMatch(\n        `/main/musicsheet/${encodeURIComponent(localPluginName)}/:sheetId`,\n    );\n    const currentSheetId = sheetIdMatch?.params?.sheetId;\n    const musicSheets = MusicSheet.frontend.useAllSheets();\n    const navigate = useNavigate();\n    const { t } = useTranslation();\n\n    const importablePlugins = useSupportedPlugin(\"importMusicSheet\");\n\n    return (\n        <div className=\"side-bar-container--my-sheets\">\n            <div className=\"divider\"></div>\n            <Disclosure defaultOpen>\n                <Disclosure.Button className=\"title\" as=\"div\" role=\"button\">\n                    <div className=\"my-sheets\">{t(\"side_bar.my_sheets\")}</div>\n                    <div\n                        role=\"button\"\n                        className=\"option-btn\"\n                        title={t(\"plugin.method_import_music_sheet\")}\n                        onClick={(e) => {\n                            e.stopPropagation();\n                            showModal(\"ImportMusicSheet\", {\n                                plugins: importablePlugins,\n                            });\n                        }}\n                    >\n                        <SvgAsset iconName=\"arrow-left-end-on-rectangle\"></SvgAsset>\n                    </div>\n                    <div\n                        role=\"button\"\n                        className=\"option-btn\"\n                        title={t(\"side_bar.create_local_sheet\")}\n                        onClick={(e) => {\n                            e.stopPropagation();\n                            showModal(\"AddNewSheet\");\n                        }}\n                    >\n                        <SvgAsset iconName=\"plus\"></SvgAsset>\n                    </div>\n                </Disclosure.Button>\n                <Disclosure.Panel>\n                    {musicSheets.map((item) => (\n                        <ListItem\n                            key={item.id}\n                            iconName={\n                                item.id === defaultSheet.id ? \"heart-outline\" : \"musical-note\"\n                            }\n                            onClick={() => {\n                                if (currentSheetId !== item.id) {\n                                    navigate(`/main/musicsheet/${encodeURIComponent(localPluginName)}/${encodeURIComponent(item.id)}`);\n                                }\n                            }}\n                            onContextMenu={(e) => {\n                                if (item.id === defaultSheet.id) {\n                                    return;\n                                }\n                                showContextMenu({\n                                    x: e.clientX,\n                                    y: e.clientY,\n                                    menuItems: [\n                                        {\n                                            title: t(\"side_bar.rename_sheet\"),\n                                            icon: \"pencil-square\",\n                                            show: item.id !== defaultSheet.id,\n                                            onClick() {\n                                                showModal(\"SimpleInputWithState\", {\n                                                    placeholder: t(\n                                                        \"modal.create_local_sheet_placeholder\",\n                                                    ),\n                                                    maxLength: 30,\n                                                    title: t(\"side_bar.rename_sheet\"),\n                                                    defaultValue: item.title,\n                                                    async onOk(text) {\n                                                        await MusicSheet.frontend.updateSheet(item.id, {\n                                                            title: text,\n                                                        });\n                                                        hideModal();\n                                                    },\n                                                });\n                                            },\n                                        },\n                                        {\n                                            title: t(\"side_bar.delete_sheet\"),\n                                            icon: \"trash\",\n                                            show: item.id !== defaultSheet.id,\n                                            onClick() {\n                                                MusicSheet.frontend.removeSheet(item.id).then(() => {\n                                                    if (currentSheetId === item.id) {\n                                                        navigate(\n                                                            `/main/musicsheet/${encodeURIComponent(localPluginName)}/${defaultSheet.id}`,\n                                                            {\n                                                                replace: true,\n                                                            },\n                                                        );\n                                                    }\n                                                });\n                                            },\n                                        },\n                                    ],\n                                });\n                            }}\n                            selected={currentSheetId === item.id}\n                            title={\n                                item.id === defaultSheet.id\n                                    ? t(\"media.default_favorite_sheet_name\")\n                                    : item.title\n                            }\n                        ></ListItem>\n                    ))}\n                </Disclosure.Panel>\n            </Disclosure>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/StarredSheets/index.scss",
    "content": ".side-bar-container--starred-sheets {\n\n  & .title {\n    height: 3rem;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding-left: 1rem;\n    padding-right: 0.5rem;\n    opacity: 0.7;\n    user-select: none;\n\n    $tag-size: 4px;\n    & .my-sheets {\n      display: flex;\n      align-items: center;\n      &::after {\n        content: \"\";\n        width: 0;\n        height: 0;\n        margin-left: 0.5rem;\n        border: $tag-size solid  transparent;\n        border-left-color: currentColor;\n        transform-origin: left center;\n        transition: transform linear 100ms;\n      }\n    }\n\n    &[data-headlessui-state=\"open\"] {\n      & .my-sheets {\n        &::after {\n          transform: rotate(90deg);\n        }\n      }\n    }\n\n\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/components/SideBar/widgets/StarredSheets/index.tsx",
    "content": "import \"./index.scss\";\nimport ListItem from \"../ListItem\";\nimport { useMatch, useNavigate } from \"react-router-dom\";\nimport { Disclosure } from \"@headlessui/react\";\nimport MusicSheet, { defaultSheet } from \"@/renderer/core/music-sheet\";\nimport { localPluginName } from \"@/common/constant\";\nimport { showContextMenu } from \"@/renderer/components/ContextMenu\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function StarredSheets() {\n    const sheetIdMatch = useMatch(\"/main/musicsheet/:platform/:sheetId\");\n\n    const currentPlatform = sheetIdMatch?.params?.platform;\n    const currentSheetId = sheetIdMatch?.params?.sheetId;\n\n    const starredSheets = MusicSheet.frontend.useAllStarredSheets();\n\n    const navigate = useNavigate();\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"side-bar-container--starred-sheets\">\n            <Disclosure defaultOpen>\n                <Disclosure.Button className=\"title\" as=\"div\" role=\"button\">\n                    <div className=\"my-sheets\">{t(\"side_bar.starred_sheets\")}</div>\n                </Disclosure.Button>\n                <Disclosure.Panel>\n                    {starredSheets.map((item) => (\n                        <ListItem\n                            key={item.id}\n                            iconName={\"musical-note\"}\n                            onClick={() => {\n                                if (\n                                    !(\n                                        currentSheetId === item.id &&\n                    currentPlatform === item.platform\n                                    )\n                                ) {\n                                    // 如果不是相同歌单\n                                    navigate(`/main/musicsheet/${item.platform}/${item.id}`, {\n                                        state: {\n                                            sheetItem: item,\n                                        },\n                                    });\n                                }\n                            }}\n                            onContextMenu={(e) => {\n                                showContextMenu({\n                                    x: e.clientX,\n                                    y: e.clientY,\n                                    menuItems: [\n                                        {\n                                            title: t(\"side_bar.unstar_sheet\"),\n                                            icon: \"trash\",\n                                            onClick() {\n                                                MusicSheet.frontend.unstarMusicSheet(item).then(() => {\n                                                    if (\n                                                        currentSheetId === item.id &&\n                            currentPlatform === item.platform\n                                                    ) {\n                                                        navigate(\n                                                            `/main/musicsheet/${localPluginName}/${defaultSheet.id}`,\n                                                            {\n                                                                replace: true,\n                                                            },\n                                                        );\n                                                    }\n                                                });\n                                            },\n                                        },\n                                    ],\n                                });\n                            }}\n                            selected={\n                                currentSheetId === item.id && currentPlatform === item.platform\n                            }\n                            title={item.title}\n                        ></ListItem>\n                    ))}\n                </Disclosure.Panel>\n            </Disclosure>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/index.scss",
    "content": ".page-container {\n  flex: auto;\n  overflow-y: auto;\n  overflow-x: hidden;\n  width: 100%;\n  padding-left: 1.5rem;\n  padding-right: 1.5rem;\n  position: relative;\n}\n\n.page-container-full-width {\n  padding-left: 0;\n  padding-right: 0;\n}\n\n.page-container-fw {\n  @extend .page-container;\n  padding-left: 0;\n  padding-right: 0;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/index.tsx",
    "content": "import { Route, Routes } from \"react-router-dom\";\nimport SideBar from \"./components/SideBar\";\nimport PluginManagerView from \"./views/plugin-manager-view\";\nimport MusicSheetView from \"./views/music-sheet-view\";\nimport SearchView from \"./views/search-view\";\nimport AlbumView from \"./views/album-view\";\nimport ArtistView from \"./views/artist-view\";\nimport ToplistView from \"./views/toplist-view\";\nimport TopListDetailView from \"./views/toplist-detail-view\";\nimport RecommendSheetsView from \"./views/recommend-sheets-view\";\nimport SettingView from \"./views/setting-view\";\nimport LocalMusicView from \"./views/local-music-view\";\nimport Empty from \"@/renderer/components/Empty\";\nimport DownloadView from \"./views/download-view\";\nimport ThemeView from \"./views/theme-view\";\nimport RecentlyPlayView from \"./views/recently-play-view\";\n\nimport \"./index.scss\";\n\nexport default function MainPage() {\n    return (\n        <>\n            <SideBar></SideBar>\n            <Routes>\n                <Route path=\"search/:query\" element={<SearchView></SearchView>}></Route>\n                <Route\n                    path=\"plugin-manager-view\"\n                    element={<PluginManagerView></PluginManagerView>}\n                ></Route>\n                <Route\n                    path=\"musicsheet/:platform/:id\"\n                    element={<MusicSheetView></MusicSheetView>}\n                ></Route>\n                <Route\n                    path=\"album/:platform/:id\"\n                    element={<AlbumView></AlbumView>}\n                ></Route>\n                <Route\n                    path=\"artist/:platform/:id\"\n                    element={<ArtistView></ArtistView>}\n                ></Route>\n                <Route path=\"toplist\" element={<ToplistView></ToplistView>}></Route>\n                <Route\n                    path=\"toplist-detail/:platform\"\n                    element={<TopListDetailView></TopListDetailView>}\n                ></Route>\n                <Route\n                    path=\"recommend-sheets\"\n                    element={<RecommendSheetsView></RecommendSheetsView>}\n                ></Route>\n                <Route\n                    path=\"local-music\"\n                    element={<LocalMusicView></LocalMusicView>}\n                ></Route>\n                <Route path=\"download\" element={<DownloadView></DownloadView>}></Route>\n                <Route path=\"setting\" element={<SettingView></SettingView>}></Route>\n                <Route path=\"theme\" element={<ThemeView></ThemeView>}></Route>\n                <Route\n                    path=\"recently_play\"\n                    element={<RecentlyPlayView></RecentlyPlayView>}\n                ></Route>\n                <Route path=\"*\" element={<Empty></Empty>}></Route>\n            </Routes>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/album-view/hooks/useAlbumDetail.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nconst idleCode = [\n    RequestStateCode.IDLE,\n    RequestStateCode.FINISHED,\n    RequestStateCode.PARTLY_DONE,\n];\n\nexport default function useAlbumDetail(\n    originalAlbumItem: IAlbum.IAlbumItem | null,\n) {\n    const currentPageRef = useRef(1);\n    const [requestState, setRequestState] = useState<RequestStateCode>(\n        RequestStateCode.IDLE,\n    );\n    const [albumItem, setAlbumItem] = useState<IAlbum.IAlbumItem | null>(\n        originalAlbumItem,\n    );\n    const [musicList, setMusicList] = useState<IMusic.IMusicItem[]>(\n        originalAlbumItem?.musicList ?? [],\n    );\n\n    const getAlbumDetail = useCallback(\n        async function () {\n            if (originalAlbumItem === null || !idleCode.includes(requestState)) {\n                return;\n            }\n\n            try {\n                setRequestState(\n                    currentPageRef.current === 1\n                        ? RequestStateCode.PENDING_FIRST_PAGE\n                        : RequestStateCode.PENDING_REST_PAGE,\n                );\n                const result = await PluginManager.callPluginDelegateMethod(\n                    originalAlbumItem,\n                    \"getAlbumInfo\",\n                    originalAlbumItem,\n                    currentPageRef.current,\n                );\n\n                if (result === null || result === undefined) {\n                    throw new Error();\n                }\n                if (result?.albumItem) {\n                    setAlbumItem((prev) => ({\n                        ...(prev ?? {}),\n                        ...(result.albumItem as IAlbum.IAlbumItem),\n                        platform: originalAlbumItem.platform,\n                    }));\n                }\n                if (result?.musicList) {\n                    const currentPage = currentPageRef.current;\n                    setMusicList((prev) => {\n                        if (currentPage === 1) {\n                            return result?.musicList ?? prev;\n                        } else {\n                            return [...prev, ...(result.musicList ?? [])];\n                        }\n                    });\n                }\n                setRequestState(\n                    result.isEnd\n                        ? RequestStateCode.FINISHED\n                        : RequestStateCode.PARTLY_DONE,\n                );\n                currentPageRef.current += 1;\n            } catch (e) {\n                setRequestState(RequestStateCode.IDLE);\n            }\n        },\n        [requestState],\n    );\n\n    useEffect(() => {\n        getAlbumDetail();\n    }, []);\n    console.log(musicList);\n\n    return [requestState, albumItem, musicList, getAlbumDetail] as const;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/album-view/index.scss",
    "content": ""
  },
  {
    "path": "src/renderer/pages/main-page/views/album-view/index.tsx",
    "content": "import MusicSheetlikeView from \"@/renderer/components/MusicSheetlikeView\";\nimport { useParams } from \"react-router-dom\";\nimport { useMemo } from \"react\";\nimport \"./index.scss\";\nimport useAlbumDetail from \"./hooks/useAlbumDetail\";\n\nexport default function AlbumView() {\n    const params = useParams();\n    const originalAlbumItem = useMemo(() => {\n        const sheetInState = history.state.usr?.albumItem ?? {};\n\n        return {\n            ...sheetInState,\n            platform: params?.platform,\n            id: params?.id,\n        } as IAlbum.IAlbumItem;\n    }, [params?.platform, params?.id]);\n\n    const [requestState, albumItem, musicList, getAlbumDetail] =\n    useAlbumDetail(originalAlbumItem);\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            <MusicSheetlikeView\n                musicSheet={albumItem}\n                musicList={musicList}\n                onLoadMore={getAlbumDetail}\n                state={requestState}\n            ></MusicSheetlikeView>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Body/index.scss",
    "content": ".artist-view--body-container {\n    margin-top: 12px;\n\n    & .tab-panel-container{\n        min-height: 300px;\n    }\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Body/index.tsx",
    "content": "import { Tab } from \"@headlessui/react\";\nimport \"./index.scss\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport SwitchCase from \"@/renderer/components/SwitchCase\";\nimport MusicResult from \"./widgets/MusicResult\";\nimport AlbumResult from \"./widgets/AlbumResult\";\n\ninterface IBodyProps {\n    artistItem: IArtist.IArtistItem;\n}\n\nconst supportedMediaType = [\"music\", \"album\"];\nexport default function Body(props: IBodyProps) {\n    const { artistItem } = props;\n    const [currentMediaType, setCurrentMediaType] = useState(\"music\");\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"artist-view--body-container\">\n            <Tab.Group\n                onChange={(index) => {\n                    setCurrentMediaType(supportedMediaType[index]);\n                }}\n            >\n                <Tab.List className=\"tab-list-container\">\n                    {supportedMediaType.map((type) => (\n                        <Tab key={type} as=\"div\" className=\"tab-list-item\">\n                            {t(`media.media_type_${type}`)}\n                        </Tab>\n                    ))}\n                </Tab.List>\n                <Tab.Panels className={\"tab-panels-container\"}>\n                    {supportedMediaType.map((type) => (\n                        <Tab.Panel className=\"tab-panel-container\" key={type}>\n                            <SwitchCase.Switch switch={type}>\n                                <SwitchCase.Case case={\"music\"}>\n                                    <MusicResult artistItem={artistItem}></MusicResult>\n                                </SwitchCase.Case>\n                                <SwitchCase.Case case={\"album\"}>\n                                    <AlbumResult artistItem={artistItem}></AlbumResult>\n                                </SwitchCase.Case>\n                            </SwitchCase.Switch>\n                        </Tab.Panel>\n                    ))}\n                </Tab.Panels>\n            </Tab.Group>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Body/widgets/AlbumResult/index.scss",
    "content": ".artist-view--album-result-container {\n    margin-top: 14px;\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Body/widgets/AlbumResult/index.tsx",
    "content": "import { useEffect } from \"react\";\nimport useQueryArtist from \"../../../../hooks/useQueryArtist\";\nimport { queryResultStore } from \"../../../../store\";\nimport Condition from \"@/renderer/components/Condition\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\nimport MusicSheetlikeList from \"@/renderer/components/MusicSheetlikeList\";\nimport \"./index.scss\";\nimport { useNavigate } from \"react-router-dom\";\n\ninterface IBodyProps {\n    artistItem: IArtist.IArtistItem;\n}\n\nexport default function AlbumResult(props: IBodyProps) {\n    const { artistItem } = props;\n    const queryArtist = useQueryArtist();\n    const queryResult = queryResultStore.useValue().album;\n\n    const navigate = useNavigate();\n\n    useEffect(() => {\n        queryArtist(artistItem, 1, \"album\");\n    }, []);\n\n    return (\n        <div className=\"artist-view--album-result-container\">\n            <Condition\n                condition={\n                    queryResult.state &&\n          queryResult.state !== RequestStateCode.PENDING_FIRST_PAGE\n                }\n                falsy={<Loading></Loading>}\n            >\n                <MusicSheetlikeList\n                    data={queryResult.data ?? []}\n                    state={queryResult.state}\n                    onClick={(mediaItem) => {\n                        navigate(`/main/album/${encodeURIComponent(mediaItem.platform)}/${encodeURIComponent(mediaItem.id)}`, {\n                            state: {\n                                albumItem: mediaItem,\n                            },\n                        });\n                    }}\n                    onLoadMore={() => {\n                        queryArtist(artistItem, undefined, \"album\");\n                    }}\n                ></MusicSheetlikeList>\n            </Condition>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Body/widgets/MusicResult/index.tsx",
    "content": "import MusicList from \"@/renderer/components/MusicList\";\nimport { useEffect } from \"react\";\nimport useQueryArtist from \"../../../../hooks/useQueryArtist\";\nimport { queryResultStore } from \"../../../../store\";\nimport Condition from \"@/renderer/components/Condition\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\n\ninterface IBodyProps {\n    artistItem: IArtist.IArtistItem;\n}\n\nexport default function MusicResult(props: IBodyProps) {\n    const { artistItem } = props;\n    const queryArtist = useQueryArtist();\n    const queryResult = queryResultStore.useValue().music;\n\n    useEffect(() => {\n        queryArtist(artistItem, 1, \"music\");\n    }, []);\n\n    return (\n        <Condition\n            condition={\n                queryResult.state &&\n        queryResult.state !== RequestStateCode.PENDING_FIRST_PAGE\n            }\n            falsy={<Loading></Loading>}\n        >\n            <MusicList\n                musicList={queryResult.data ?? []}\n                state={queryResult.state}\n                onPageChange={() => {\n                    queryArtist(artistItem, undefined, \"music\");\n                }}\n            ></MusicList>\n        </Condition>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Header/index.scss",
    "content": ".artist-view--header-container {\n    margin-top: 24px;\n    display: flex;\n    min-height: 160px;\n  \n    & img {\n      width: 160px;\n      height: 160px;\n      border-radius: 12px;\n      user-select: none;\n      -webkit-user-drag: none;\n      object-fit: cover;\n    }\n  \n    & .artist-info {\n      flex: 1;\n      padding-left: 1.2rem;\n  \n      & .title-container {\n        display: flex;\n        align-items: center;\n        -webkit-user-drag: none;\n        user-select: text;\n  \n        & .title {\n          flex: 1;\n          font-size: 1.4rem;\n          font-weight: 600;\n          margin-left: 8px;\n          overflow: hidden;\n          display: -webkit-box;\n          -webkit-line-clamp: 1;\n          -webkit-box-orient: vertical;\n        }\n      }\n  \n      & .info-container {\n        margin-top: 16px;\n        font-size: 0.9rem;\n        opacity: 0.8;\n        line-height: 2;\n  \n        & span {\n          margin-right: 24px;\n        }\n      }\n  \n      & .description-container {\n        &[data-fold=\"true\"] {\n          display: -webkit-box;\n          -webkit-line-clamp: 5;\n          -webkit-box-orient: vertical;\n          overflow: hidden;\n        }\n      }\n    }\n  }\n  "
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/components/Header/index.tsx",
    "content": "import { setFallbackAlbum } from \"@/renderer/utils/img-on-error\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport Tag from \"@/renderer/components/Tag\";\nimport Condition from \"@/renderer/components/Condition\";\nimport \"./index.scss\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IProps {\n    artistItem: IArtist.IArtistItem;\n}\n\nexport default function Header(props: IProps) {\n    const { artistItem } = props;\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"artist-view--header-container\">\n            <img\n                draggable={false}\n                src={artistItem?.avatar ?? albumImg}\n                onError={setFallbackAlbum}\n            ></img>\n            <div className=\"artist-info\">\n                <div className=\"title-container\">\n                    <Tag>{artistItem?.platform}</Tag>\n                    <div className=\"title\">\n                        {artistItem?.name ?? t(\"media.unknown_artist\")}\n                    </div>\n                </div>\n\n                <Condition condition={artistItem?.description}>\n                    <div\n                        className=\"info-container description-container\"\n                        data-fold=\"true\"\n                        onClick={(e) => {\n                            const dataset = e.currentTarget.dataset;\n                            dataset.fold = dataset.fold === \"true\" ? \"false\" : \"true\";\n                        }}\n                    >\n                        {artistItem?.description}\n                    </div>\n                </Condition>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/hooks/useQueryArtist.ts",
    "content": "import { produce } from \"immer\";\nimport { useCallback } from \"react\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport { queryResultStore } from \"../store\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nconst setQueryResults = queryResultStore.setValue;\n\nexport default function useQueryArtist() {\n    const queryResults = queryResultStore.useValue();\n\n    const queryArtist = useCallback(\n        async (\n            artist: IArtist.IArtistItem,\n            page?: number,\n            type: IArtist.ArtistMediaType = \"music\",\n        ) => {\n            const prevResult = queryResults[type];\n            if (\n                prevResult?.state & RequestStateCode.PENDING_FIRST_PAGE ||\n        prevResult?.state === RequestStateCode.FINISHED ||\n        page <= prevResult.page\n            ) {\n                return;\n            }\n            page = page ?? (prevResult.page ?? 0) + 1;\n            try {\n                setQueryResults(\n                    produce((draft) => {\n                        draft[type].state =\n              page === 1\n                  ? RequestStateCode.PENDING_FIRST_PAGE\n                  : RequestStateCode.PENDING_REST_PAGE;\n                    }),\n                );\n                const result = await PluginManager.callPluginDelegateMethod(\n                    artist,\n                    \"getArtistWorks\",\n                    artist,\n                    page,\n                    type,\n                );\n\n                setQueryResults(\n                    produce((draft) => {\n                        draft[type].page = page;\n                        draft[type].state =\n              result?.isEnd === false\n                  ? RequestStateCode.PARTLY_DONE\n                  : RequestStateCode.FINISHED;\n                        draft[type].data = (draft[type].data ?? [] as any[]).concat(\n                            result?.data ?? [],\n                        );\n                    }),\n                );\n            } catch (e) {\n                setQueryResults(\n                    produce((draft) => {\n                        draft[type].state = page === 1 ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE;\n                    }),\n                );\n            }\n        },\n        [queryResults],\n    );\n\n    return queryArtist;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/index.scss",
    "content": ".artist-view--container {}"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/index.tsx",
    "content": "import { useParams } from \"react-router-dom\";\nimport Header from \"./components/Header\";\nimport \"./index.scss\";\nimport { useEffect, useMemo } from \"react\";\nimport Body from \"./components/Body\";\nimport { initQueryResult, queryResultStore } from \"./store\";\n\nexport default function ArtistView() {\n    const params = useParams();\n\n    const artistItem = useMemo(() => {\n        const artistInState = history.state.usr?.artistItem ?? {};\n\n        return {\n            ...artistInState,\n            platform: params?.platform,\n            id: params?.id,\n        } as IArtist.IArtistItem;\n    }, [params?.platform, params?.id]);\n\n    useEffect(() => {\n        return () => {\n            queryResultStore.setValue(initQueryResult);\n        };\n    });\n\n    return (\n        <div id=\"page-container\" className=\"page-container artist-view--container\">\n            <Header artistItem={artistItem}></Header>\n            <Body artistItem={artistItem}></Body>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/artist-view/store/index.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport Store from \"@/common/store\";\n\n\nexport interface IQueryResult<\n    T extends IArtist.ArtistMediaType = IArtist.ArtistMediaType,\n> {\n    state?: RequestStateCode;\n    page?: number;\n    data?: IMedia.SupportMediaItem[T][];\n}\n\ntype IQueryResults<\n    K extends IArtist.ArtistMediaType = IArtist.ArtistMediaType,\n> = {\n    [T in K]: IQueryResult<T>;\n};\n\nexport const initQueryResult: IQueryResults = {\n    music: {},\n    album: {},\n};\n\nexport const queryResultStore = new Store(initQueryResult);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/components/Downloaded/index.tsx",
    "content": "import MusicList from \"@/renderer/components/MusicList\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport { useRef } from \"react\";\n\nexport default function Downloaded() {\n    const downloadedList = Downloader.useDownloadedMusicList();\n    const musicListContainerRef = useRef<HTMLDivElement>();\n\n    return (\n        <div ref={musicListContainerRef}>\n            <MusicList\n                musicList={downloadedList}\n                virtualProps={{\n                    getScrollElement() {\n                        return document.querySelector(\"#page-container\");\n                    },\n                    offsetHeight: () => musicListContainerRef.current.offsetTop,\n                }}\n            ></MusicList>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/components/Downloading/DownloadStatus.tsx",
    "content": "import { DownloadState } from \"@/common/constant\";\nimport { isSameMedia } from \"@/common/media-util\";\nimport { normalizeFileSize } from \"@/common/normalize-util\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport React from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\ninterface IProps {\n    musicItem: IMusic.IMusicItem;\n}\n\nfunction DownloadStatus(props: IProps) {\n    const { musicItem } = props;\n\n    const { t } = useTranslation();\n\n    const downloadStatus = Downloader.useDownloadStatus(musicItem);\n    if (!downloadStatus) {\n        return <span>-</span>;\n    } else if (downloadStatus.state === DownloadState.WAITING) {\n        return <span>{t(\"download_page.waiting\")}</span>;\n    } else if (downloadStatus.state === DownloadState.ERROR) {\n        return (\n            <span style={{ color: \"var(--dangerColor, #FC5F5F)\" }}>\n                {t(\"download_page.failed\")}: {downloadStatus.msg}\n            </span>\n        );\n    } else if (downloadStatus.state === DownloadState.DOWNLOADING) {\n        return (\n            <span\n                style={{\n                    color: \"var(--infoColor, #0A95C8)\",\n                }}\n            >\n                {normalizeFileSize(downloadStatus.downloaded ?? 0)} /{\" \"}\n                {normalizeFileSize(downloadStatus.total ?? 0)}\n            </span>\n        );\n    }\n}\n\nexport default React.memo(DownloadStatus, (prev, curr) =>\n    isSameMedia(prev.musicItem, curr.musicItem),\n);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/components/Downloading/index.scss",
    "content": ".downloading-container {\n  width: 100%;\n  min-height: 300px;\n\n\n\n  & td {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n\n  }\n\n \n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/components/Downloading/index.tsx",
    "content": "import Tag from \"@/renderer/components/Tag\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport {\n    createColumnHelper,\n    flexRender,\n    getCoreRowModel,\n    useReactTable,\n} from \"@tanstack/react-table\";\nimport \"./index.scss\";\nimport { i18n } from \"@/shared/i18n/renderer\";\nimport useVirtualList from \"@/hooks/useVirtualList\";\nimport DownloadStatus from \"./DownloadStatus\";\n\nconst columnHelper = createColumnHelper<IMusic.IMusicItem>();\n\nconst estimizeItemHeight = 2.6 * 13; // lineheight 2.6rem\n\nconst { t } = i18n;\nconst columnDef = [\n    columnHelper.accessor((_, index) => index + 1, {\n        cell: (info) => info.getValue(),\n        header: () => \"#\",\n        id: \"index\",\n        minSize: 40,\n        maxSize: 40,\n        size: 40,\n    }),\n    columnHelper.accessor(\"title\", {\n        header: () => t(\"media.media_title\"),\n        size: 200,\n        cell: (info) => <span title={info.getValue()}>{info.getValue()}</span>,\n    }),\n\n    columnHelper.accessor(\"artist\", {\n        header: () => t(\"media.media_type_artist\"),\n        size: 80,\n        cell: (info) => <span title={info.getValue()}>{info.getValue()}</span>,\n    }),\n    columnHelper.accessor(\"album\", {\n        header: () => t(\"media.media_type_album\"),\n        size: 80,\n        cell: (info) => <span title={info.getValue()}>{info.getValue()}</span>,\n    }),\n    columnHelper.display({\n        header: () => t(\"common.status\"),\n        size: 180,\n        id: \"status\",\n        cell: (info) => {\n            return <DownloadStatus musicItem={info.row.original}></DownloadStatus>;\n        },\n    }),\n    columnHelper.accessor(\"platform\", {\n        header: () => t(\"media.media_platform\"),\n        size: 100,\n        cell: (info) => <Tag fill>{info.getValue()}</Tag>,\n    }),\n];\n\nexport default function Downloading() {\n    const downloadingQueue = Downloader.useDownloadingMusicList();\n\n    const table = useReactTable({\n        debugAll: false,\n        data: downloadingQueue,\n        columns: columnDef,\n        getCoreRowModel: getCoreRowModel(),\n    });\n\n    const virtualController = useVirtualList({\n        data: table.getRowModel().rows,\n        scrollElementQuery: \"#page-container\",\n        estimateItemHeight: estimizeItemHeight,\n    });\n\n    return (\n        <div className=\"downloading-container\">\n            <table\n                style={{\n                    tableLayout: \"fixed\",\n                    height: virtualController.totalHeight + estimizeItemHeight,\n                }}\n            >\n                <thead>\n                    <tr>\n                        {table.getHeaderGroups()[0].headers.map((header) => (\n                            <th\n                                key={header.id}\n                                style={{\n                                    width: header.id === \"extra\" ? undefined : header.getSize(),\n                                }}\n                            >\n                                {flexRender(\n                                    header.column.columnDef.header,\n                                    header.getContext(),\n                                )}\n                            </th>\n                        ))}\n                    </tr>\n                </thead>\n                <tbody\n                    style={{\n                        transform: `translateY(${virtualController.startTop}px)`,\n                    }}\n                >\n                    {virtualController.virtualItems.map((virtualItem, index) => {\n                        const dataItem = virtualItem.dataItem;\n                        const musicItem = dataItem.original;\n                        // todo 拆出一个组件\n                        return (\n                            <tr\n                                key={`${musicItem.platform}-${musicItem.id}`}\n                                // data-active={\n                                //   activeItems.length === 2\n                                //     ? isBetween(\n                                //         virtualItem.rowIndex,\n                                //         activeItems[0],\n                                //         activeItems[1]\n                                //       )\n                                //     : activeItems[0] === virtualItem.rowIndex\n                                // }\n\n                                onClick={() => {\n                                    // 如果点击的时候按下shift\n                                }}\n                            >\n                                {dataItem.getAllCells().map((cell) => (\n                                    <td\n                                        key={cell.id}\n                                        style={{\n                                            width: cell.column.getSize(),\n                                        }}\n                                    >\n                                        {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                    </td>\n                                ))}\n                            </tr>\n                        );\n                    })}\n                </tbody>\n                <tfoot\n                    style={{\n                        height:\n              virtualController.totalHeight -\n              virtualController.virtualItems.length * estimizeItemHeight,\n                    }}\n                ></tfoot>\n            </table>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/index.scss",
    "content": ".download-view--container {\n    & .header {\n      font-weight: 600;\n      font-size: 1.5rem;\n      margin-top: 1.5rem;\n      margin-bottom: 1.2rem;\n      letter-spacing: 0.05rem;\n      user-select: none;\n    }\n  \n\n  }\n  "
  },
  {
    "path": "src/renderer/pages/main-page/views/download-view/index.tsx",
    "content": "import { Tab } from \"@headlessui/react\";\nimport \"./index.scss\";\nimport Downloaded from \"./components/Downloaded\";\nimport Downloading from \"./components/Downloading\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function DownloadView() {\n    const { t } = useTranslation();\n\n    return (\n        <div\n            id=\"page-container\"\n            className=\"page-container download-view--container\"\n        >\n            <Tab.Group>\n                <Tab.List className=\"tab-list-container\">\n                    <Tab as=\"div\" className=\"tab-list-item\">\n                        {t(\"common.downloaded\")}\n                    </Tab>\n                    <Tab as=\"div\" className=\"tab-list-item\">\n                        {t(\"common.downloading\")}\n                    </Tab>\n                </Tab.List>\n                <Tab.Panels className={\"tab-panels-container\"}>\n                    <Tab.Panel className=\"tab-panel-container\">\n                        <Downloaded></Downloaded>\n                    </Tab.Panel>\n                    <Tab.Panel className=\"tab-panel-container\">\n                        <Downloading></Downloading>\n                    </Tab.Panel>\n                </Tab.Panels>\n            </Tab.Group>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/index.scss",
    "content": ".local-music-view--container {\n  display: flex;\n  flex-direction: column;\n  min-height: 100%;\n\n  &[data-full-page=\"true\"] {\n    max-height: 100%;\n    height: 100%;\n  }\n\n  & .header {\n    font-weight: 600;\n    font-size: 1.5rem;\n    margin-top: 1.5rem;\n    margin-bottom: 1.2rem;\n    letter-spacing: 0.05rem;\n    user-select: none;\n  }\n\n  & .operations {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n\n    & .operations-layout {\n      display: flex;\n      align-items: center;\n\n      & .search-local-music {\n        margin-right: 12px;\n      }\n\n      & .list-view-action {\n        width: 2.4rem;\n        height: 2rem;\n        border-radius: 4px;\n        cursor: pointer;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n\n        & svg {\n          width: 1.5rem;\n          height: 1.5rem;\n        }\n        &:hover {\n          background-color: var(--listHoverColor);\n          color: var(--primaryColor);\n        }\n        &[data-selected=\"true\"] {\n          background-color: var(--listActiveColor);\n          color: var(--primaryColor);\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/index.tsx",
    "content": "import localMusicListStore from \"@/renderer/core/local-music/store\";\nimport { useTranslation } from \"react-i18next\";\n\nimport \"./index.scss\";\nimport { showModal } from \"@/renderer/components/Modal\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { useEffect, useState, useTransition } from \"react\";\nimport SwitchCase from \"@/renderer/components/SwitchCase\";\nimport ListView from \"./views/list\";\nimport ArtistView from \"./views/artist\";\nimport AlbumView from \"./views/album\";\nimport FolderView from \"./views/folder\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\nenum DisplayView {\n    LIST,\n    ARTIST,\n    ALBUM,\n    FOLDER,\n}\n\nexport default function LocalMusicView() {\n    const { t } = useTranslation();\n    const [displayView, setDisplayView] = useState(DisplayView.LIST);\n\n    const localMusicList = localMusicListStore.useValue();\n    const [inputSearch, setInputSearch] = useState(\"\");\n    const [filterMusicList, setFilterMusicList] = useState<\n    IMusic.IMusicItem[] | null\n    >(null);\n\n    const [isPending, startTransition] = useTransition();\n\n    useEffect(() => {\n        if (inputSearch.trim() === \"\") {\n            setFilterMusicList(null);\n        } else {\n            startTransition(() => {\n                const caseSensitive = AppConfig.getConfig(\n                    \"playMusic.caseSensitiveInSearch\",\n                );\n                if (caseSensitive) {\n                    setFilterMusicList(\n                        localMusicListStore\n                            .getValue()\n                            .filter(\n                                (item) =>\n                                    item.title?.includes(inputSearch) ||\n                  item.artist?.includes(inputSearch) ||\n                  item.album?.includes(inputSearch),\n                            ),\n                    );\n                } else {\n                    const searchText = inputSearch.toLocaleLowerCase();\n                    setFilterMusicList(\n                        localMusicListStore\n                            .getValue()\n                            .filter(\n                                (item) =>\n                                    item.title?.toLocaleLowerCase()?.includes(searchText) ||\n                  item.artist?.toLocaleLowerCase()?.includes(searchText) ||\n                  item.album?.toLocaleLowerCase()?.includes(searchText),\n                            ),\n                    );\n                }\n            });\n        }\n    }, [inputSearch]);\n\n    const finalMusicList = filterMusicList ?? localMusicList;\n\n    return (\n        <div\n            id=\"page-container\"\n            className=\"page-container local-music-view--container\"\n            data-full-page={displayView !== DisplayView.LIST}\n        >\n            <div className=\"header\">{t(\"local_music_page.local_music\")}</div>\n            <div className=\"operations\">\n                <div\n                    data-type=\"normalButton\"\n                    role=\"button\"\n                    onClick={() => {\n                        showModal(\"WatchLocalDir\");\n                    }}\n                >\n                    {t(\"local_music_page.auto_scan\")}\n                </div>\n                <div className=\"operations-layout\">\n                    <input\n                        className=\"search-local-music\"\n                        spellCheck={false}\n                        onChange={(evt) => {\n                            setInputSearch(evt.target.value);\n                        }}\n                        placeholder={t(\"local_music_page.search_local_music\")}\n                    ></input>\n                    <div\n                        className=\"list-view-action\"\n                        data-selected={displayView === DisplayView.LIST}\n                        title={t(\"local_music_page.list_view\")}\n                        onClick={() => {\n                            setDisplayView(DisplayView.LIST);\n                        }}\n                    >\n                        <SvgAsset iconName=\"musical-note\"></SvgAsset>\n                    </div>\n                    <div\n                        className=\"list-view-action\"\n                        data-selected={displayView === DisplayView.ARTIST}\n                        title={t(\"local_music_page.artist_view\")}\n                        onClick={() => {\n                            setDisplayView(DisplayView.ARTIST);\n                        }}\n                    >\n                        <SvgAsset iconName=\"user\"></SvgAsset>\n                    </div>\n                    <div\n                        className=\"list-view-action\"\n                        data-selected={displayView === DisplayView.ALBUM}\n                        title={t(\"local_music_page.album_view\")}\n                        onClick={() => {\n                            setDisplayView(DisplayView.ALBUM);\n                        }}\n                    >\n                        <SvgAsset iconName=\"cd\"></SvgAsset>\n                    </div>\n                    <div\n                        className=\"list-view-action\"\n                        data-selected={displayView === DisplayView.FOLDER}\n                        title={t(\"local_music_page.folder_view\")}\n                        onClick={() => {\n                            setDisplayView(DisplayView.FOLDER);\n                        }}\n                    >\n                        <SvgAsset iconName=\"folder-open\"></SvgAsset>\n                    </div>\n                </div>\n            </div>\n            <SwitchCase.Switch switch={displayView}>\n                <SwitchCase.Case case={DisplayView.LIST}>\n                    <ListView localMusicList={finalMusicList}></ListView>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={DisplayView.ARTIST}>\n                    <ArtistView localMusicList={finalMusicList}></ArtistView>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={DisplayView.ALBUM}>\n                    <AlbumView localMusicList={finalMusicList}></AlbumView>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={DisplayView.FOLDER}>\n                    <FolderView localMusicList={finalMusicList}></FolderView>\n                </SwitchCase.Case>\n            </SwitchCase.Switch>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/album/index.scss",
    "content": ".local-music--album-view-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px 0;\n  display: flex;\n  align-items: stretch;\n\n  & .left-part {\n    width: 150px;\n    border-right: 1px solid var(--dividerColor);\n    overflow-y: auto;\n    flex-shrink: 0;\n\n    & .album-item {\n      height: 4rem;\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      padding: 0 6px;\n      cursor: pointer;\n      user-select: none;\n\n      & span {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      & span:nth-child(2) {\n        margin-top: 2px;\n        font-size: 0.8rem;\n        opacity: 0.7;\n      }\n    }\n  }\n\n  & .right-part {\n    flex: 1;\n    padding-left: 12px;\n    overflow-y: auto;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/album/index.tsx",
    "content": "import localMusicListStore from \"@/renderer/core/local-music/store\";\nimport \"./index.scss\";\nimport { useMemo, useState } from \"react\";\nimport groupBy from \"@/renderer/utils/groupBy\";\nimport MusicList from \"@/renderer/components/MusicList\";\n\ninterface IProps {\n    localMusicList: IMusic.IMusicItem[];\n}\n\nexport default function AlbumView(props: IProps) {\n    const { localMusicList } = props;\n\n    const [keys, allMusic] = useMemo(() => {\n        const grouped = groupBy(\n            localMusicList ?? [],\n            (it) => `${it.album} - ${it.artist}`,\n        );\n        return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped];\n    }, [localMusicList]);\n\n    const [selectedKey, setSelectedKey] = useState<string>();\n\n    const actualSelectedKey = selectedKey ?? keys?.[0];\n\n    return (\n        <div className=\"local-music--album-view-container\">\n            <div className=\"left-part\">\n                {keys.map((it) => (\n                    <div\n                        className=\"album-item list-behavior\"\n                        key={it}\n                        data-selected={actualSelectedKey === it}\n                        onClick={() => {\n                            setSelectedKey(it);\n                        }}\n                    >\n                        <span>{it.split(\" - \")[0]}</span>\n                        <span>{it.split(\" - \")[1]}</span>\n                    </div>\n                ))}\n            </div>\n            <div className=\"right-part\">\n                <MusicList\n                    musicList={allMusic[actualSelectedKey as any] ?? []}\n                    hideRows={[\"album\"]}\n                    virtualProps={{\n                        fallbackRenderCount: -1,\n                    }}\n                ></MusicList>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/artist/index.scss",
    "content": ".local-music--artist-view-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px 0;\n  display: flex;\n  align-items: stretch;\n\n  & .left-part {\n    width: 150px;\n    border-right: 1px solid var(--dividerColor);\n    overflow-y: auto;\n    flex-shrink: 0;\n\n    & .artist-item {\n      height: 4rem;\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      padding: 0 6px;\n      cursor: pointer;\n      user-select: none;\n\n      & span {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      & span:nth-child(2) {\n        margin-top: 2px;\n        font-size: 0.8rem;\n        opacity: 0.7;\n      }\n    }\n  }\n\n  & .right-part {\n    flex: 1;\n    padding-left: 12px;\n    overflow-y: auto;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/artist/index.tsx",
    "content": "import localMusicListStore from \"@/renderer/core/local-music/store\";\nimport \"./index.scss\";\nimport { useMemo, useState } from \"react\";\nimport groupBy from \"@/renderer/utils/groupBy\";\nimport MusicList from \"@/renderer/components/MusicList\";\nimport { Trans } from \"react-i18next\";\n\ninterface IProps {\n    localMusicList: IMusic.IMusicItem[];\n}\n\nexport default function ArtistView(props: IProps) {\n    const { localMusicList } = props;\n\n    const [keys, allMusic] = useMemo(() => {\n        const grouped = groupBy(localMusicList ?? [], (it) => it.artist);\n        return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped];\n    }, [localMusicList]);\n\n    const [selectedKey, setSelectedKey] = useState<string>();\n\n    const actualSelectedKey = selectedKey ?? keys?.[0];\n\n    return (\n        <div className=\"local-music--artist-view-container\">\n            <div className=\"left-part\">\n                {keys.map((it) => (\n                    <div\n                        className=\"artist-item list-behavior\"\n                        key={it}\n                        data-selected={actualSelectedKey === it}\n                        onClick={() => {\n                            setSelectedKey(it);\n                        }}\n                    >\n                        <span>{it}</span>\n                        <span>\n                            <Trans\n                                i18nKey={\"local_music_page.total_music_num\"}\n                                values={{\n                                    number: allMusic?.[it]?.length ?? 0,\n                                }}\n                            ></Trans>\n                        </span>\n                    </div>\n                ))}\n            </div>\n            <div className=\"right-part\">\n                <MusicList\n                    musicList={allMusic[actualSelectedKey] ?? []}\n                    hideRows={[\"artist\"]}\n                    virtualProps={{\n                        fallbackRenderCount: -1,\n                    }}\n                ></MusicList>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/folder/index.scss",
    "content": ".local-music--folder-view-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px 0;\n  display: flex;\n  align-items: stretch;\n\n  & .left-part {\n    width: 200px;\n    border-right: 1px solid var(--dividerColor);\n    overflow-y: auto;\n    flex-shrink: 0;\n\n    & .folder-item {\n      height: 4rem;\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      padding: 0 6px;\n      cursor: pointer;\n      user-select: none;\n\n      & span {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      & span:nth-child(2) {\n        margin-top: 2px;\n        font-size: 0.8rem;\n        opacity: 0.7;\n      }\n    }\n  }\n\n  & .right-part {\n    flex: 1;\n    padding-left: 12px;\n    overflow-y: auto;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/folder/index.tsx",
    "content": "import localMusicListStore from \"@/renderer/core/local-music/store\";\nimport \"./index.scss\";\nimport { useMemo, useState } from \"react\";\nimport groupBy from \"@/renderer/utils/groupBy\";\nimport MusicList from \"@/renderer/components/MusicList\";\nimport { Trans } from \"react-i18next\";\n\ninterface IProps {\n    localMusicList: IMusic.IMusicItem[];\n}\n\nexport default function FolderView(props: IProps) {\n    const { localMusicList } = props;\n\n    const [keys, allMusic] = useMemo(() => {\n        const grouped = groupBy(localMusicList ?? [], (it) =>\n            window.path.dirname(it.$$localPath),\n        );\n        return [Object.keys(grouped).sort((a, b) => a.localeCompare(b)), grouped];\n    }, [localMusicList]);\n\n    const [selectedKey, setSelectedKey] = useState<string>();\n\n    const actualSelectedKey = selectedKey ?? keys?.[0];\n\n    return (\n        <div className=\"local-music--folder-view-container\">\n            <div className=\"left-part\">\n                {keys.map((it) => (\n                    <div\n                        className=\"folder-item list-behavior\"\n                        key={it}\n                        data-selected={actualSelectedKey === it}\n                        onClick={() => {\n                            setSelectedKey(it);\n                        }}\n                    >\n                        <span>{it}</span>\n                        <span>\n                            <Trans\n                                i18nKey={\"local_music_page.total_music_num\"}\n                                values={{\n                                    number: allMusic?.[it]?.length ?? 0,\n                                }}\n                            ></Trans>\n                        </span>\n                    </div>\n                ))}\n            </div>\n            <div className=\"right-part\">\n                <MusicList\n                    musicList={allMusic[actualSelectedKey] ?? []}\n                    hideRows={[\"artist\"]}\n                    virtualProps={{\n                        fallbackRenderCount: -1,\n                    }}\n                ></MusicList>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/local-music-view/views/list/index.tsx",
    "content": "import MusicList from \"@/renderer/components/MusicList\";\nimport localMusicListStore from \"@/renderer/core/local-music/store\";\n\ninterface IProps {\n    localMusicList: IMusic.IMusicItem[];\n}\n\nexport default function ListView(props: IProps) {\n    const { localMusicList } = props;\n\n    return (\n        <MusicList\n            containerStyle={{\n                marginTop: \"12px\",\n            }}\n            musicList={localMusicList}\n            virtualProps={{\n                fallbackRenderCount: 40,\n                getScrollElement() {\n                    return document.querySelector(\"#page-container\");\n                },\n                offsetHeight: 102,\n            }}\n        ></MusicList>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/index.scss",
    "content": ""
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/index.tsx",
    "content": "import { useParams } from \"react-router-dom\";\nimport { localPluginName } from \"@/common/constant\";\nimport LocalSheet from \"./local-sheet\";\nimport RemoteSheet from \"./remote-sheet\";\n\nimport \"./index.scss\";\n\n/**\n * path: /main/musicsheet/platform/id\n *\n * state: {\n *  musicSheet: IMusic.MusicSheetItem\n * }\n *\n */\nexport default function MusicSheetView() {\n    const { platform } = useParams() ?? {};\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            {platform === localPluginName ? (\n                <LocalSheet></LocalSheet>\n            ) : (\n                <RemoteSheet></RemoteSheet>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/local-sheet/index.tsx",
    "content": "import { useParams } from \"react-router-dom\";\nimport MusicSheetlikeView from \"@/renderer/components/MusicSheetlikeView\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport MusicSheet, { defaultSheet } from \"@/renderer/core/music-sheet\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function LocalSheet() {\n    const { id } = useParams() ?? {};\n    const [musicSheet, loading] = MusicSheet.frontend.useMusicSheet(id);\n    const { t } = useTranslation();\n\n    const _musicSheet =\n    id === defaultSheet.id\n        ? {\n            ...musicSheet,\n            title: t(\"media.default_favorite_sheet_name\"),\n        }\n        : musicSheet;\n\n    return (\n        <MusicSheetlikeView\n            hidePlatform\n            musicSheet={_musicSheet}\n            state={loading}\n            musicList={musicSheet?.musicList ?? []}\n        ></MusicSheetlikeView>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/remote-sheet/hooks/usePluginSheetMusicList.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { isSameMedia } from \"@/common/media-util\";\nimport { useEffect, useRef, useState } from \"react\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function usePluginSheetMusicList(\n    platform: string,\n    id: string,\n    originalSheetItem?: IMusic.IMusicSheetItem | null, // 额外的输入\n) {\n    const [requestState, setRequestState] = useState<RequestStateCode>(\n        RequestStateCode.IDLE,\n    );\n    const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>({\n        ...originalSheetItem,\n        platform,\n        id,\n    });\n    const [musicList, setMusicList] = useState<IMusic.IMusicItem[]>(\n        originalSheetItem?.musicList ?? [],\n    );\n\n    // 当前正在搜索的信息\n    const currentSheetItemRef = useRef<IMusic.IMusicSheetItem | null>(null);\n    // 页码\n    const currentPageRef = useRef(1);\n\n    const getSheetDetail = async () => {\n        if (!isSameMedia(currentSheetItemRef.current, originalSheetItem)) {\n            // 1.1 如果是切换了新的歌单\n            // 恢复初始状态 并设置当前的歌曲项\n            currentSheetItemRef.current = {\n                ...originalSheetItem,\n                platform,\n                id,\n            };\n            setSheetItem(currentSheetItemRef.current);\n            setMusicList(originalSheetItem?.musicList ?? []);\n            currentPageRef.current = 1;\n        } else if (requestState & RequestStateCode.PENDING_FIRST_PAGE) {\n            // 1.2 如果是原有歌单，并且在loading中，返回\n            return;\n        }\n\n        try {\n            // 2. 设置初始状态\n            setRequestState(\n                currentPageRef.current === 1\n                    ? RequestStateCode.PENDING_FIRST_PAGE\n                    : RequestStateCode.PENDING_REST_PAGE,\n            );\n            // 3. 调用获取音乐详情接口\n            const sheetItem = currentSheetItemRef.current;\n            const result = await PluginManager.callPluginDelegateMethod(\n                sheetItem,\n                \"getMusicSheetInfo\",\n                sheetItem,\n                currentPageRef.current,\n            );\n\n            if (!isSameMedia(currentSheetItemRef.current, sheetItem)) {\n                // 出现竞态 结果直接舍弃\n                return;\n            }\n            if (result === null || result === undefined) {\n                throw new Error();\n            }\n            // 3. 如果在页码为1的时候返回了sheetItem，重新设置下sheetItem\n            if (result?.sheetItem && currentPageRef.current <= 1) {\n                setSheetItem((prev) => ({\n                    ...(prev ?? {}),\n                    ...(result.sheetItem as IMusic.IMusicSheetItem),\n                    platform: originalSheetItem.platform,\n                }));\n            }\n            // 4. 如果返回了音乐列表\n            if (result?.musicList) {\n                setMusicList((prev) => {\n                    if (currentPageRef.current === 1) {\n                        return result?.musicList ?? prev;\n                    } else {\n                        return [...prev, ...(result.musicList ?? [])];\n                    }\n                });\n            }\n            setRequestState(\n                result.isEnd ? RequestStateCode.FINISHED : RequestStateCode.PARTLY_DONE,\n            );\n            currentPageRef.current += 1;\n        } catch {\n            setRequestState(\n                currentPageRef.current === 1\n                    ? RequestStateCode.FINISHED\n                    : RequestStateCode.PARTLY_DONE,\n            );\n        }\n    };\n\n    useEffect(() => {\n        if (platform && id) {\n            getSheetDetail();\n        }\n    }, [platform, id]);\n\n    return [requestState, sheetItem, musicList, getSheetDetail] as const;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/remote-sheet/index.tsx",
    "content": "import React from \"react\";\nimport { useParams } from \"react-router-dom\";\nimport usePluginSheetMusicList from \"./hooks/usePluginSheetMusicList\";\nimport MusicSheetlikeView from \"@/renderer/components/MusicSheetlikeView\";\nimport { isSameMedia } from \"@/common/media-util\";\n\nimport MusicSheet from \"@/renderer/core/music-sheet\";\nimport { useTranslation } from \"react-i18next\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\n\nexport default function RemoteSheet() {\n    const { platform, id } = useParams() ?? {};\n\n    const [state, sheetItem, musicList, getSheetDetail] = usePluginSheetMusicList(\n        platform,\n        id,\n        history.state?.usr?.sheetItem,\n    );\n    return (\n        <MusicSheetlikeView\n            musicSheet={sheetItem}\n            musicList={musicList}\n            state={state}\n            onLoadMore={() => {\n                getSheetDetail();\n            }}\n            options={<RemoteSheetOptions sheetItem={sheetItem}></RemoteSheetOptions>}\n        />\n    );\n}\n\ninterface IProps {\n    sheetItem: IMusic.IMusicSheetItem;\n}\nfunction RemoteSheetOptions(props: IProps) {\n    const { sheetItem } = props;\n    const starredMusicSheets = MusicSheet.frontend.useAllStarredSheets();\n    const { t } = useTranslation();\n\n    const isStarred = starredMusicSheets.find((item) =>\n        isSameMedia(sheetItem, item),\n    );\n\n    return (\n        <>\n            <div\n                role=\"button\"\n                className=\"option-button\"\n                data-type=\"normalButton\"\n                onClick={() => {\n                    if (isStarred) {\n                        MusicSheet.frontend.unstarMusicSheet(sheetItem);\n                    } else {\n                        MusicSheet.frontend.starMusicSheet(sheetItem);\n                    }\n                }}\n            >\n                <SvgAsset\n                    iconName={isStarred ? \"heart\" : \"heart-outline\"}\n                    color={isStarred ? \"red\" : undefined}\n                ></SvgAsset>\n                <span>{t(\"music_sheet_like_view.star\")}</span>\n            </div>\n        </>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/music-sheet-view/store/musicSheetStore.ts",
    "content": "import Store from \"@/common/store\";\n\nexport default new Store<IMusic.IMusicSheetItem | null>(null);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/plugin-manager-view/components/plugin-table/index.scss",
    "content": ".plugin-table--container {\n  width: calc(100% - 1rem);\n  flex: 1;\n\n  & .action-button {\n    cursor: pointer;\n    margin-right: 0.8rem;\n\n    &:hover {\n      font-weight: 500;\n      color: var(--primaryColor) !important;\n      border-bottom: 1px solid currentColor;\n    }\n  }\n\n  & tr {\n    position: relative;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/plugin-manager-view/components/plugin-table/index.tsx",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\n\nimport {\n    useReactTable,\n    createColumnHelper,\n    getCoreRowModel,\n    flexRender,\n} from \"@tanstack/react-table\";\nimport \"./index.scss\";\nimport { CSSProperties, ReactNode } from \"react\";\nimport Condition, { IfTruthy } from \"@/renderer/components/Condition\";\nimport { hideModal, showModal } from \"@/renderer/components/Modal\";\nimport Empty from \"@/renderer/components/Empty\";\nimport { toast } from \"react-toastify\";\nimport { showPanel } from \"@/renderer/components/Panel\";\nimport DragReceiver, { startDrag } from \"@/renderer/components/DragReceiver\";\nimport { produce } from \"immer\";\nimport { i18n } from \"@/shared/i18n/renderer\";\nimport PluginManager, { useSortedPlugins } from \"@shared/plugin-manager/renderer\";\n\nconst t = i18n.t;\n\nfunction renderOptions(info: any) {\n    const row = info.row.original as IPlugin.IPluginDelegate;\n\n    return (\n        <div>\n            <ActionButton\n                style={{\n                    color: \"var(--dangerColor, #FC5F5F)\",\n                }}\n                onClick={() => {\n                    showModal(\"Reconfirm\", {\n                        title: t(\"plugin_management_page.uninstall_plugin\"),\n                        content: t(\"plugin_management_page.confirm_text_uninstall_plugin\", {\n                            plugin: row.platform,\n                        }),\n                        async onConfirm() {\n                            hideModal();\n                            try {\n                                await PluginManager.uninstallPlugin(row.hash);\n                                toast.success(\n                                    t(\"plugin_management_page.uninstall_successfully\", {\n                                        plugin: row.platform,\n                                    }),\n                                );\n                            } catch {\n                                toast.error(t(\"plugin_management_page.uninstall_failed\"));\n                            }\n                        },\n                    });\n                }}\n            >\n                {t(\"plugin_management_page.uninstall\")}\n            </ActionButton>\n            <Condition condition={row.srcUrl}>\n                <ActionButton\n                    style={{\n                        color: \"var(--successColor, #08A34C)\",\n                    }}\n                    onClick={async () => {\n                        try {\n                            await PluginManager.installPluginFromRemote(row.srcUrl);\n                            toast.success(\n                                t(\"plugin_management_page.toast_plugin_is_latest\", {\n                                    plugin: row.platform,\n                                }),\n                            );\n                        } catch (e) {\n                            toast.error(\n                                e?.message ?? t(\"plugin_management_page.update_failed\"),\n                            );\n                        }\n                    }}\n                >\n                    {t(\"plugin_management_page.update\")}\n                </ActionButton>\n            </Condition>\n\n            <Condition condition={row.supportedMethod.includes(\"importMusicItem\")}>\n                <ActionButton\n                    style={{\n                        color: \"var(--infoColor, #0A95C8)\",\n                    }}\n                    onClick={() => {\n                        showModal(\"SimpleInputWithState\", {\n                            title: t(\"plugin.method_import_music_item\"),\n                            withLoading: true,\n                            loadingText: t(\"plugin_management_page.importing_media\"),\n                            placeholder: t(\n                                \"plugin_management_page.placeholder_import_music_item\",\n                                {\n                                    plugin: row.platform,\n                                },\n                            ),\n                            maxLength: 1000,\n                            onOk(text) {\n                                return PluginManager.callPluginDelegateMethod(\n                                    row,\n                                    \"importMusicItem\",\n                                    text.trim(),\n                                );\n                            },\n                            onPromiseResolved(result) {\n                                hideModal();\n                                showModal(\"AddMusicToSheet\", {\n                                    musicItems: result as IMusic.IMusicItem[],\n                                });\n                            },\n                            onPromiseRejected() {\n                                console.log(t(\"plugin_management_page.import_failed\"));\n                            },\n                            hints: row.hints?.importMusicItem,\n                        });\n                    }}\n                >\n                    {t(\"plugin.method_import_music_item\")}\n                </ActionButton>\n            </Condition>\n            <Condition condition={row.supportedMethod.includes(\"importMusicSheet\")}>\n                <ActionButton\n                    style={{\n                        color: \"#0A95C8\",\n                    }}\n                    onClick={() => {\n                        showModal(\"SimpleInputWithState\", {\n                            title: t(\"plugin.method_import_music_sheet\"),\n                            withLoading: true,\n                            loadingText: t(\"plugin_management_page.importing_media\"),\n                            placeholder: t(\n                                \"plugin_management_page.placeholder_import_music_sheet\",\n                                {\n                                    plugin: row.platform,\n                                },\n                            ),\n                            maxLength: 1000,\n                            onOk(text) {\n                                return PluginManager.callPluginDelegateMethod(\n                                    row,\n                                    \"importMusicSheet\",\n                                    text.trim(),\n                                );\n                            },\n                            onPromiseResolved(result) {\n                                hideModal();\n                                showModal(\"AddMusicToSheet\", {\n                                    musicItems: result as IMusic.IMusicItem[],\n                                });\n                            },\n                            onPromiseRejected() {\n                                toast.error(t(\"plugin_management_page.import_failed\"));\n                            },\n                            hints: row.hints?.importMusicSheet,\n                        });\n                    }}\n                >\n                    {t(\"plugin.method_import_music_sheet\")}\n                </ActionButton>\n            </Condition>\n            <Condition condition={row.userVariables?.length}>\n                <ActionButton\n                    style={{\n                        color: \"#0A95C8\",\n                    }}\n                    onClick={() => {\n                        showPanel(\"UserVariables\", {\n                            variables: row.userVariables,\n                            plugin: row,\n                            initValues:\n                AppConfig.getConfig(\"private.pluginMeta\")?.[row.platform]\n                    ?.userVariables,\n                        });\n                    }}\n                >\n                    {t(\"plugin.prop_user_variable\")}\n                </ActionButton>\n            </Condition>\n        </div>\n    );\n}\n\nconst columnHelper = createColumnHelper<IPlugin.IPluginDelegate>();\nconst columnDef = [\n    columnHelper.accessor((_, index) => index + 1, {\n        id: \"id\",\n        cell(info) {\n            return info.getValue();\n        },\n        header: () => \"#\",\n        minSize: 64,\n        maxSize: 64,\n        size: 64,\n    }),\n    columnHelper.accessor(\"platform\", {\n        cell: (info) => info.getValue(),\n        header: () => t(\"media.media_platform\"),\n        minSize: 150,\n        size: 200,\n    }),\n    columnHelper.accessor(\"version\", {\n        cell: (info) => info.getValue(),\n        header: () => t(\"common.version_code\"),\n        minSize: 100,\n        maxSize: 100,\n        size: 100,\n    }),\n    columnHelper.accessor(\"author\", {\n        cell: (info) => info.getValue() ?? t(\"media.unknown_artist\"),\n        header: () => t(\"media.media_type_artist\"),\n        maxSize: 100,\n        minSize: 100,\n        size: 100,\n    }),\n    columnHelper.accessor(() => 0, {\n        id: \"extra\",\n        cell: renderOptions,\n        header: () => t(\"common.operation\"),\n    }),\n];\n\nexport default function PluginTable() {\n    const plugins = useSortedPlugins();\n    const table = useReactTable({\n        data: plugins,\n        columns: columnDef,\n        getCoreRowModel: getCoreRowModel(),\n    });\n\n    function onDrop(fromIndex: number, toIndex: number) {\n        const meta = AppConfig.getConfig(\"private.pluginMeta\") ?? {};\n\n        const newPlugins = plugins\n            .slice(0, fromIndex)\n            .concat(plugins.slice(fromIndex + 1));\n        newPlugins.splice(\n            fromIndex < toIndex ? toIndex - 1 : toIndex,\n            0,\n            plugins[fromIndex],\n        );\n\n        const newMeta = produce(meta, (draft) => {\n            newPlugins.forEach((plugin, index) => {\n                if (!draft[plugin.platform]) {\n                    draft[plugin.platform] = {};\n                }\n                draft[plugin.platform].order = index;\n            });\n        });\n\n        AppConfig.setConfig({\n            \"private.pluginMeta\": newMeta,\n        });\n    }\n\n    return (\n        <div className=\"plugin-table--container\">\n            <Condition\n                condition={table.getRowModel().rows.length}\n                falsy={<Empty></Empty>}\n            >\n                <table>\n                    <thead>\n                        <tr>\n                            {table.getHeaderGroups()[0].headers.map((header) => (\n                                <th\n                                    key={header.id}\n                                    style={{\n                                        width: header.id === \"extra\" ? \"100%\" : header.getSize(),\n                                    }}\n                                >\n                                    {flexRender(\n                                        header.column.columnDef.header,\n                                        header.getContext(),\n                                    )}\n                                </th>\n                            ))}\n                        </tr>\n                    </thead>\n                    <tbody>\n                        {table.getRowModel().rows.map((row, index) => (\n                            <tr\n                                key={row.id}\n                                draggable\n                                onDragStart={(e) => {\n                                    startDrag(e, index);\n                                }}\n                            >\n                                {row.getAllCells().map((cell) => (\n                                    <td\n                                        key={cell.id}\n                                        style={{\n                                            width: cell.column.getSize(),\n                                            whiteSpace: \"nowrap\",\n                                            overflow: \"hidden\",\n                                            textOverflow: \"ellipsis\",\n                                        }}\n                                    >\n                                        {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                                    </td>\n                                ))}\n                                <IfTruthy condition={index === 0}>\n                                    <DragReceiver\n                                        position=\"top\"\n                                        rowIndex={0}\n                                        insideTable\n                                        onDrop={onDrop}\n                                    ></DragReceiver>\n                                </IfTruthy>\n                                <DragReceiver\n                                    position=\"bottom\"\n                                    rowIndex={index + 1}\n                                    insideTable\n                                    onDrop={onDrop}\n                                ></DragReceiver>\n                            </tr>\n                        ))}\n                    </tbody>\n                </table>\n            </Condition>\n        </div>\n    );\n}\n\ninterface IActionButtonProps {\n    children: ReactNode;\n    onClick?: () => void;\n    style?: CSSProperties;\n}\nfunction ActionButton(props: IActionButtonProps) {\n    const { children, onClick, style } = props;\n    return (\n        <span className=\"action-button\" onClick={onClick} style={style}>\n            {children}\n        </span>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/plugin-manager-view/index.scss",
    "content": ".plugin-manager-view-container {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  box-sizing: border-box;\n\n  & .header {\n    font-weight: 600;\n    font-size: 1.5rem;\n    margin-top: 1.5rem;\n    margin-bottom: 1.2rem;\n    letter-spacing: 0.05rem;\n    user-select: none;\n  }\n\n  & .operation-area {\n    margin-bottom: 1.5rem;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n\n    & .left-part,\n    & .right-part {\n      display: flex;\n      gap: 12px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/plugin-manager-view/index.tsx",
    "content": "import { hideModal, showModal } from \"@/renderer/components/Modal\";\nimport PluginTable from \"./components/plugin-table\";\nimport \"./index.scss\";\nimport { getUserPreference } from \"@/renderer/utils/user-perference\";\nimport { toast } from \"react-toastify\";\nimport A from \"@/renderer/components/A\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { dialogUtil } from \"@shared/utils/renderer\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function PluginManagerView() {\n    const { t } = useTranslation();\n\n    return (\n        <div\n            id=\"page-container\"\n            className=\"page-container plugin-manager-view-container\"\n        >\n            <div className=\"header\">\n                {t(\"plugin_management_page.plugin_management\")}\n            </div>\n            <div className=\"operation-area\">\n                <div className=\"left-part\">\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={async () => {\n                            try {\n                                const result = await dialogUtil.showOpenDialog({\n                                    title: t(\"plugin_management_page.choose_plugin\"),\n                                    buttonLabel: t(\"plugin_management_page.install\"),\n                                    filters: [\n                                        {\n                                            extensions: [\"js\", \"json\"],\n                                            name: t(\"plugin_management_page.musicfree_plugin\"),\n                                        },\n                                    ],\n                                });\n                                if (result.canceled) {\n                                    return;\n                                }\n                                await PluginManager.installPluginFromLocal(result.filePaths[0]);\n                                toast.success(t(\"plugin_management_page.install_successfully\"));\n                            } catch (e) {\n                                toast.warn(\n                                    `${t(\"plugin_management_page.install_failed\")}: ${\n                                        e.message ?? t(\"plugin_management_page.invalid_plugin\")\n                                    }`,\n                                );\n                            }\n                        }}\n                    >\n                        {t(\"plugin_management_page.install_from_local_file\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            showModal(\"SimpleInputWithState\", {\n                                title: t(\"plugin_management_page.install_plugin_from_network\"),\n                                placeholder: t(\n                                    \"plugin_management_page.error_hint_plugin_should_end_with_js_or_json\",\n                                ),\n                                okText: t(\"plugin_management_page.install\"),\n                                loadingText: t(\"plugin_management_page.installing\"),\n                                withLoading: true,\n                                async onOk(text) {\n                                    if (\n                                        text.trim().endsWith(\".json\") ||\n                    text.trim().endsWith(\".js\")\n                                    ) {\n                                        return PluginManager.installPluginFromRemote(text);\n                                    } else {\n                                        throw new Error(\n                                            t(\n                                                \"plugin_management_page.error_hint_plugin_should_end_with_js_or_json\",\n                                            ),\n                                        );\n                                    }\n                                },\n                                onPromiseResolved() {\n                                    toast.success(\n                                        t(\"plugin_management_page.install_successfully\"),\n                                    );\n                                    hideModal();\n                                },\n                                onPromiseRejected(e) {\n                                    toast.warn(\n                                        `${t(\"plugin_management_page.install_failed\")}: ${\n                                            e.message ?? t(\"plugin_management_page.invalid_plugin\")\n                                        }`,\n                                    );\n                                },\n                                hints: [\n                                    <Trans\n                                        i18nKey={\"plugin_management_page.info_hint_install_plugin\"}\n                                        components={{\n                                            a: <A href=\"https://musicfree.catcat.work\"></A>,\n                                        }}\n                                    ></Trans>,\n                                ],\n                            });\n                        }}\n                    >\n                        {t(\"plugin_management_page.install_plugin_from_network\")}\n                    </div>\n                    {/* <div\n            role=\"button\"\n            data-type=\"normalButton\"\n            onClick={() => {\n              showModal(\"SimpleInputWithState\", {\n                title: \"从网络安装插件\",\n                placeholder: \"请输入插件源地址(链接以json或js结尾)\",\n                okText: \"安装\",\n                loadingText: \"安装中\",\n                withLoading: true,\n                async onOk(text) {\n                  if (\n                    text.trim().endsWith(\".json\") ||\n                    text.trim().endsWith(\".js\")\n                  ) {\n                    return ipcRendererInvoke(\"install-plugin-remote\", text);\n                  } else {\n                    throw new Error(\"插件链接需要以json或者js结尾\");\n                  }\n                },\n                onPromiseResolved() {\n                  toast.success(\"安装成功~\");\n                  hideModal();\n                },\n                onPromiseRejected(e) {\n                  toast.warn(`安装失败: ${e.message ?? \"无效插件\"}`);\n                },\n                hints: [\n                  \"插件需要满足 MusicFree 特定的插件协议，具体可在官方网站中查看\",\n                ],\n              });\n            }}\n          >\n            一键更新\n          </div> */}\n                </div>\n                <div className=\"right-part\">\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={() => {\n                            showModal(\"PluginSubscription\");\n                        }}\n                    >\n                        {t(\"plugin_management_page.subscription_setting\")}\n                    </div>\n                    <div\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        onClick={async () => {\n                            const subscription = getUserPreference(\"subscription\");\n\n                            if (subscription?.length) {\n                                for (let i = 0; i < subscription.length; ++i) {\n                                    await PluginManager.installPluginFromRemote(subscription[i].srcUrl);\n                                }\n                                toast.success(t(\"plugin_management_page.update_successfully\"));\n                            } else {\n                                toast.warn(t(\"plugin_management_page.no_subscription\"));\n                            }\n                        }}\n                    >\n                        {t(\"plugin_management_page.update_subscription\")}\n                    </div>\n                </div>\n            </div>\n            <PluginTable></PluginTable>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recently-play-view/index.tsx",
    "content": "import MusicSheetlikeView from \"@/renderer/components/MusicSheetlikeView\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport {\n    clearRecentlyPlaylist,\n    useRecentlyPlaylistSheet,\n} from \"@/renderer/core/recently-playlist\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function RecentlyPlayView() {\n    const recentlyPlaylistSheet = useRecentlyPlaylistSheet();\n    const { t } = useTranslation();\n\n    const options = (\n        <>\n            <div\n                role=\"button\"\n                className=\"option-button\"\n                data-type=\"normalButton\"\n                data-disabled={!recentlyPlaylistSheet.playCount}\n                onClick={() => {\n                    clearRecentlyPlaylist();\n                }}\n            >\n                <SvgAsset iconName={\"trash\"}></SvgAsset>\n                <span>{t(\"common.clear\")}</span>\n            </div>\n        </>\n    );\n\n    console.log(recentlyPlaylistSheet);\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            <MusicSheetlikeView\n                hidePlatform\n                musicSheet={recentlyPlaylistSheet}\n                musicList={recentlyPlaylistSheet.musicList}\n                options={options}\n            ></MusicSheetlikeView>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/index.scss",
    "content": ".recommend-sheet-view--body-container {\n  & .tags-container {\n    margin-top: 16px;\n    position: relative;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 10px 14px;\n    align-items: center;\n    $tag-size: 4px;\n\n    & .first-tag {\n      flex-shrink: 0;\n      font-size: 1rem;\n      display: flex;\n      align-items: center;\n\n      &::after {\n        content: \"\";\n        display: block;\n        width: 0;\n        height: 0;\n        margin-left: 0.5rem;\n        border: $tag-size solid transparent;\n        border-left-color: currentColor;\n        transform-origin: left center;\n        transition: transform linear 100ms;\n      }\n\n      &[data-panel-open=\"true\"] {\n        &::after {\n          transform: rotate(90deg);\n        }\n      }\n    }\n\n    & .pinned-tag {\n      font-size: 1rem;\n      opacity: 0.8;\n      white-space: nowrap;\n      \n      cursor: pointer;\n    }\n  }\n\n  & .list-container{\n    margin-top: 16px;\n    min-height: 300px;\n    height: 300px;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport \"./index.scss\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport useRecommendListTags from \"../../hooks/useRecommendListTags\";\nimport TagPanel from \"./tag-panel\";\nimport useRecommendSheets from \"../../hooks/useRecommendSheets\";\nimport MusicSheetlikeList from \"@/renderer/components/MusicSheetlikeList\";\nimport Condition from \"@/renderer/components/Condition\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\nimport { useNavigate } from \"react-router-dom\";\nimport { i18n } from \"@/shared/i18n/renderer\";\n\nexport function getDefaultTag(): IMedia.IUnique {\n    return {\n        title: i18n.t(\"common.default\"),\n        id: \"\",\n    };\n}\n\ninterface IBodyProps {\n    plugin: IPlugin.IPluginDelegate;\n}\n\nexport default function Body(props: IBodyProps) {\n    const { plugin } = props;\n    // 选中的tag\n    const [selectedTag, setSelectedTag] = useState<IMedia.IUnique | null>(null);\n\n    // 第一个tag\n    const [firstTag, setFirstTag] = useState<IMedia.IUnique>(getDefaultTag);\n\n    const tags = useRecommendListTags(plugin);\n    //   const tags: any[] = [];\n\n    const [showPanel, setShowPanel] = useState(false);\n\n    const [query, sheets, status] = useRecommendSheets(plugin, selectedTag);\n\n    const navigate = useNavigate();\n\n    useEffect(() => {\n        if (tags) {\n            const cachedTag = history.state?.usr?.tag;\n            if (cachedTag) {\n                if (tags.pinned?.findIndex?.((it) => it.id === cachedTag.id) === -1) {\n                    setFirstTag(cachedTag);\n                }\n                setSelectedTag(cachedTag);\n            } else {\n                setSelectedTag(getDefaultTag);\n            }\n        }\n    }, [tags]);\n\n    return (\n        <div className=\"recommend-sheet-view--body-container\">\n            <div className=\"tags-container\">\n                <TagPanel\n                    show={showPanel}\n                    tagsGroups={tags?.data}\n                    onTagClick={(tag) => {\n                        setSelectedTag(tag);\n                        setFirstTag(tag);\n                        const usr = history.state.usr ?? {};\n\n                        navigate(\"\", {\n                            replace: true,\n                            state: {\n                                ...usr,\n                                tag: tag,\n                            },\n                        });\n                        setShowPanel(false);\n                    }}\n                ></TagPanel>\n                <div\n                    className={classNames({\n                        \"first-tag\": true,\n                        highlight: selectedTag?.id === firstTag.id,\n                    })}\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    data-panel-open={showPanel}\n                    title={firstTag.title}\n                    onClick={() => {\n                        setShowPanel((prev) => !prev);\n                    }}\n                >\n                    {firstTag.title}\n                </div>\n                {tags?.pinned?.map?.((tag) => (\n                    <div\n                        key={tag.id}\n                        className={classNames({\n                            \"pinned-tag\": true,\n                            highlight: selectedTag?.id === tag.id,\n                        })}\n                        role=\"button\"\n                        data-type=\"normalButton\"\n                        title={tag.title}\n                        onClick={() => {\n                            setSelectedTag(tag);\n                            const usr = history.state.usr ?? {};\n\n                            navigate(\"\", {\n                                replace: true,\n                                state: {\n                                    ...usr,\n                                    tag: tag,\n                                },\n                            });\n                        }}\n                    >\n                        {tag.title}\n                    </div>\n                ))}\n            </div>\n            <div className=\"list-container\">\n                <Condition\n                    condition={status !== RequestStateCode.PENDING_FIRST_PAGE}\n                    falsy={<Loading></Loading>}\n                >\n                    <MusicSheetlikeList\n                        data={sheets}\n                        state={status}\n                        onLoadMore={() => {\n                            query();\n                        }}\n                        onClick={(sheetItem) => {\n                            navigate(\n                                `/main/musicsheet/${encodeURIComponent(sheetItem.platform)}/${encodeURIComponent(sheetItem.id)}`,\n                                {\n                                    state: {\n                                        sheetItem: sheetItem,\n                                    },\n                                },\n                            );\n                        }}\n                    ></MusicSheetlikeList>\n                </Condition>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/tag-panel.scss",
    "content": ".tag-panel--container {\n  position: absolute;\n  z-index: 20;\n  width: 560px;\n  max-height: 360px;\n  padding: 14px;\n  box-sizing: border-box;\n  overflow-y: auto;\n  top: calc(1rem + 25px);\n  left: 0;\n  transition: transform 100ms ease-out;\n  transform-origin: center top;\n\n  &[data-show=\"false\"] {\n    transform: scaleY(0);\n    pointer-events: none;\n    user-select: none;\n  }\n\n  &[data-show=\"true\"] {\n    transform: scaleY(1);\n    pointer-events: all;\n  }\n\n\n  & .tag-group--tag {\n    font-size: 1rem !important;\n    color: #666;\n    white-space: nowrap;\n    \n    cursor: pointer;\n  }\n\n  & .tag-group--container {\n    margin-bottom: 16px;\n    \n    & .tag-group--title {\n        margin-bottom: 12px;\n        opacity: 0.8;\n    }\n\n    & .tag-group--tags {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 10px 14px;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/components/Body/tag-panel.tsx",
    "content": "import Condition from \"@/renderer/components/Condition\";\nimport { getDefaultTag } from \".\";\nimport \"./tag-panel.scss\";\n\ninterface ITagPanelProps {\n    show: boolean;\n    tagsGroups: IMusic.IMusicSheetGroupItem[];\n    onTagClick?: (tag: IMedia.IUnique) => void;\n}\n\nexport default function TagPanel(props: ITagPanelProps) {\n    const { show, onTagClick, tagsGroups } = props;\n    const defaultTag = getDefaultTag();\n\n    return (\n        <div className=\"tag-panel--container shadow backdrop-color\" data-show={show}>\n            <div className=\"tag-group--container\">\n                <div\n                    role=\"button\"\n                    className=\"tag-group--tag\"\n                    data-type=\"normalButton\"\n                    title={defaultTag.title}\n                    onClick={() => {\n                        onTagClick?.(defaultTag);\n                    }}\n                >\n                    {defaultTag.title}\n                </div>\n            </div>\n            {tagsGroups?.map?.((tagGroup, index) => (\n                <div key={index} className=\"tag-group--container\">\n                    <Condition condition={tagGroup.title}>\n                        <div className=\"tag-group--title\">{tagGroup.title}</div>\n                    </Condition>\n                    <div className=\"tag-group--tags\">\n                        {tagGroup.data.map((tag) => (\n                            <div\n                                key={tag.id}\n                                role=\"button\"\n                                data-type=\"normalButton\"\n                                className=\"tag-group--tag\"\n                                title={tag.title}\n                                onClick={() => {\n                                    onTagClick?.(tag);\n                                }}\n                            >\n                                {tag.title}\n                            </div>\n                        ))}\n                    </div>\n                </div>\n            ))}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/hooks/useRecommendListTags.ts",
    "content": "import { useCallback, useEffect, useState } from \"react\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function (plugin: IPlugin.IPluginDelegate) {\n    const [tags, setTags] = useState<IPlugin.IGetRecommendSheetTagsResult | null>(\n        null,\n    );\n\n    const query = useCallback(async () => {\n        try {\n            const result = await PluginManager.callPluginDelegateMethod(\n                plugin,\n                \"getRecommendSheetTags\",\n            );\n            if (!result) {\n                throw new Error();\n            }\n            setTags(result);\n        } catch {\n            setTags({\n                pinned: [],\n                data: [],\n            });\n        }\n    }, []);\n\n    useEffect(() => {\n        query();\n    }, []);\n\n    return tags;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/hooks/useRecommendSheets.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { resetMediaItem } from \"@/common/media-util\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function (plugin: IPlugin.IPluginDelegate, tag: IMedia.IUnique | null) {\n    const [sheets, setSheets] = useState<IMusic.IMusicSheetItem[]>([]);\n    const [status, setStatus] = useState<RequestStateCode>(RequestStateCode.IDLE);\n    const currentTagRef = useRef<string>();\n    const pageRef = useRef(0);\n\n    const query = useCallback(async () => {\n        if (\n            (RequestStateCode.PENDING_FIRST_PAGE & status ||\n        RequestStateCode.FINISHED === status) &&\n      currentTagRef.current === tag.id\n        ) {\n            return;\n        }\n        if (currentTagRef.current !== tag.id) {\n            setSheets([]);\n            pageRef.current = 0;\n        }\n        pageRef.current++;\n        currentTagRef.current = tag.id;\n\n        setStatus(\n            pageRef.current === 1\n                ? RequestStateCode.PENDING_FIRST_PAGE\n                : RequestStateCode.PENDING_REST_PAGE,\n        );\n        const res = await PluginManager.callPluginDelegateMethod(\n            plugin,\n            \"getRecommendSheetsByTag\",\n            tag,\n            pageRef.current,\n        );\n\n        if (tag.id === currentTagRef.current) {\n            setSheets((prev) => [\n                ...prev,\n                ...res.data!.map((item) => resetMediaItem(item, plugin.platform)),\n            ]);\n        }\n\n        if (res.isEnd) {\n            setStatus(RequestStateCode.FINISHED);\n        } else {\n            setStatus(RequestStateCode.PARTLY_DONE);\n        }\n    }, [tag, status]);\n\n    useEffect(() => {\n        if (tag) {\n            query();\n        }\n    }, [tag]);\n\n    return [query, sheets, status] as const;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/recommend-sheets-view/index.tsx",
    "content": "import Condition from \"@/renderer/components/Condition\";\nimport NoPlugin from \"@/renderer/components/NoPlugin\";\nimport { Tab } from \"@headlessui/react\";\nimport { useNavigate } from \"react-router-dom\";\nimport Body from \"./components/Body\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function RecommendSheetsView() {\n    const availablePlugins = PluginManager.getSortedSupportedPlugin(\"getRecommendSheetsByTag\");\n    const navigate = useNavigate();\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            <Condition\n                condition={availablePlugins.length}\n                falsy={<NoPlugin supportMethod=\"热门歌单\" height={\"100%\"}></NoPlugin>}\n            >\n                <Tab.Group\n                    defaultIndex={history.state?.usr?.pluginIndex}\n                    onChange={(index) => {\n                        const usr = history.state.usr ?? {};\n\n                        navigate(\"\", {\n                            replace: true,\n                            state: {\n                                ...usr,\n                                pluginHash: availablePlugins[index].hash,\n                                pluginIndex: index,\n                                tag: null,\n                            },\n                        });\n                    }}\n                >\n                    <Tab.List className=\"tab-list-container\">\n                        {availablePlugins.map((plugin) => (\n                            <Tab key={plugin.hash} as=\"div\" className=\"tab-list-item\">\n                                {plugin.platform}\n                            </Tab>\n                        ))}\n                    </Tab.List>\n                    <Tab.Panels className={\"tab-panels-container\"}>\n                        {availablePlugins.map((plugin) => (\n                            <Tab.Panel className=\"tab-panel-container\" key={plugin.hash}>\n                                <Body plugin={plugin}></Body>\n                            </Tab.Panel>\n                        ))}\n                    </Tab.Panels>\n                </Tab.Group>\n            </Condition>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/AlbumResult/index.scss",
    "content": ".search-result--album-result-container {\n    width: 100%;\n\n    & .result-body {\n        display: grid;\n        width: 100%;\n        grid-template-columns: repeat(5, 1fr);\n    }\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/AlbumResult/index.tsx",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport React, { memo } from \"react\";\nimport \"./index.scss\";\nimport useSearch from \"../../../hooks/useSearch\";\nimport { useNavigate } from \"react-router-dom\";\nimport MusicSheetlikeList from \"@/renderer/components/MusicSheetlikeList\";\n\ninterface IMediaResultProps {\n    data: IAlbum.IAlbumItem[];\n    state: RequestStateCode;\n    pluginHash: string;\n}\n\nfunction AlbumResult(props: IMediaResultProps) {\n    const { data, state, pluginHash } = props;\n\n    const search = useSearch();\n    const navigate = useNavigate();\n\n    return (\n        <MusicSheetlikeList\n            data={data}\n            state={state}\n            onLoadMore={() => {\n                search(undefined, undefined, \"album\", pluginHash);\n            }}\n            onClick={(albumItem) => {\n                navigate(`/main/album/${encodeURIComponent(albumItem.platform)}/${encodeURIComponent(albumItem.id)}`, {\n                    state: {\n                        albumItem,\n                    },\n                });\n            }}\n        ></MusicSheetlikeList>\n    );\n}\n\nexport default memo(\n    AlbumResult,\n    (prev, curr) =>\n        prev.data === curr.data &&\n    prev.state === curr.state &&\n    prev.pluginHash === curr.pluginHash,\n);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/ArtistResult/index.scss",
    "content": ".search-result--artist-result-container {\n  width: 100%;\n\n  & .result-body {\n    display: grid;\n    width: 100%;\n    grid-template-columns: repeat(5, 1fr);\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/ArtistResult/index.tsx",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport BottomLoadingState from \"@/renderer/components/BottomLoadingState\";\n\nimport useSearch from \"../../../hooks/useSearch\";\nimport ArtistItem from \"@/renderer/components/ArtistItem\";\nimport \"./index.scss\";\nimport { useNavigate } from \"react-router-dom\";\n\ninterface IMediaResultProps {\n    data: IArtist.IArtistItem[];\n    state: RequestStateCode;\n    pluginHash: string;\n}\n\nexport default function ArtistResult(props: IMediaResultProps) {\n    const { data, state, pluginHash } = props;\n\n    const search = useSearch();\n    const navigate = useNavigate();\n\n    return (\n        <div className=\"search-result--artist-result-container\">\n            <div className=\"result-body\">\n                {data.map((artistItem, index) => {\n                    return <ArtistItem artistItem={artistItem} key={index} onClick={() => {\n                        navigate(\n                            `/main/artist/${encodeURIComponent(artistItem.platform)}/${encodeURIComponent(artistItem.id)}`,\n                            {\n                                state: {\n                                    artistItem,\n                                },\n                            },\n                        );\n                    }}></ArtistItem>;\n                })}\n            </div>\n            <BottomLoadingState\n                state={state}\n                onLoadMore={() => {\n                    search(undefined, undefined, \"artist\", pluginHash);\n                }}\n            ></BottomLoadingState>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/MusicResult/index.tsx",
    "content": "import React, { memo } from \"react\";\nimport MusicList from \"@/renderer/components/MusicList\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport useSearch from \"../../../hooks/useSearch\";\n\ninterface IMediaResultProps {\n    data: IMusic.IMusicItem[];\n    state: RequestStateCode;\n    pluginHash: string;\n}\n\nfunction MusicResult(props: IMediaResultProps) {\n    const { data, state, pluginHash } = props;\n    const search = useSearch();\n\n    return (\n        <MusicList\n            doubleClickBehavior=\"normal\"\n            musicList={data}\n            state={state}\n            onPageChange={() => {\n                search(undefined, undefined, \"music\", pluginHash);\n            }}\n            virtualProps={{\n                fallbackRenderCount: -1,\n            }}\n        ></MusicList>\n    );\n}\n\nexport default memo(\n    MusicResult,\n    (prev, curr) =>\n        prev.data === curr.data &&\n    prev.state === curr.state &&\n    prev.pluginHash === curr.pluginHash,\n);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/SheetResult/index.scss",
    "content": ".search-result--album-result-container {\n    width: 100%;\n\n    & .result-body {\n        display: grid;\n        width: 100%;\n        grid-template-columns: repeat(5, 1fr);\n    }\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/SheetResult/index.tsx",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { memo } from \"react\";\nimport \"./index.scss\";\nimport useSearch from \"../../../hooks/useSearch\";\nimport { useNavigate } from \"react-router-dom\";\nimport MusicSheetlikeList from \"@/renderer/components/MusicSheetlikeList\";\n\ninterface IMediaResultProps {\n    data: IAlbum.IAlbumItem[];\n    state: RequestStateCode;\n    pluginHash: string;\n}\n\nfunction SheetResult(props: IMediaResultProps) {\n    const { data, state, pluginHash } = props;\n\n    const search = useSearch();\n    const navigate = useNavigate();\n\n    return (\n        <MusicSheetlikeList\n            data={data}\n            state={state}\n            onLoadMore={() => {\n                search(undefined, undefined, \"sheet\", pluginHash);\n            }}\n            onClick={(sheetItem) => {\n                navigate(`/main/musicsheet/${encodeURIComponent(sheetItem.platform)}/${encodeURIComponent(sheetItem.id)}`, {\n                    state: {\n                        sheetItem,\n                    },\n                });\n            }}\n        ></MusicSheetlikeList>\n    );\n}\n\nexport default memo(\n    SheetResult,\n    (prev, curr) =>\n        prev.data === curr.data &&\n    prev.state === curr.state &&\n    prev.pluginHash === curr.pluginHash,\n);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/index.scss",
    "content": ".search-view--plugins {\n  display: flex;\n  flex-wrap: wrap;\n  margin-top: 12px;\n  margin-bottom: 12px;\n  font-size: 1.1rem;\n  gap: 8px 14px;\n\n  & .plugin-item {\n    box-sizing: border-box;\n    height: 2rem;\n    border-radius: 1rem;\n    padding: 2px 8px;\n\n    border: 1px solid currentColor;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    &[data-selected=\"true\"] {\n      border: none;\n      background-color: var(--primaryColor);\n      color: white;\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/components/SearchResult/index.tsx",
    "content": "import { useEffect, useState, memo } from \"react\";\nimport \"./index.scss\";\nimport Condition from \"@/renderer/components/Condition\";\nimport AlbumResult from \"./AlbumResult\";\nimport MusicResult from \"./MusicResult\";\nimport ArtistResult from \"./ArtistResult\";\nimport { searchResultsStore } from \"../../store/search-result\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\nimport useSearch from \"../../hooks/useSearch\";\nimport SwitchCase from \"@/renderer/components/SwitchCase\";\nimport { useNavigate } from \"react-router-dom\";\nimport SheetResult from \"./SheetResult\";\n\ninterface ISearchResultProps {\n    type: IMedia.SupportMediaType;\n    query: string;\n    plugins: IPlugin.IPluginDelegate[];\n}\n\nexport default function SearchResult(props: ISearchResultProps) {\n    const { type, plugins, query } = props;\n    const [selectedPlugin, setSelectedPlugin] =\n    useState<IPlugin.IPluginDelegate | null>(\n        history.state?.usr?.plugin ?? null,\n    );\n\n    useEffect(() => {\n        if (plugins.length && !selectedPlugin) {\n            setSelectedPlugin(plugins[0]);\n        }\n    }, [plugins, selectedPlugin]);\n\n    const navigate = useNavigate();\n\n    return (\n        <>\n            <div className=\"search-view--plugins\">\n                {plugins?.map?.((plugin) => (\n                    <div\n                        className=\"plugin-item\"\n                        role=\"button\"\n                        key={plugin.hash}\n                        onClick={() => {\n                            setSelectedPlugin(plugin);\n                            const usr = history.state.usr ?? {};\n\n                            // 获取history\n                            navigate(\"\", {\n                                replace: true,\n                                state: {\n                                    ...usr,\n                                    plugin: plugin,\n                                },\n                            });\n                        }}\n                        data-selected={selectedPlugin?.hash === plugin.hash}\n                    >\n                        {plugin.platform}\n                    </div>\n                ))}\n            </div>\n            <SearchResultBody\n                query={query}\n                type={type}\n                pluginHash={selectedPlugin?.hash}\n            ></SearchResultBody>\n        </>\n    );\n}\n\ninterface ISearchResultBodyProps {\n    type: IMedia.SupportMediaType;\n    pluginHash: string;\n    query: string;\n}\nfunction _SearchResultBody(props: ISearchResultBodyProps) {\n    const { type, pluginHash, query } = props;\n    const searchResults = searchResultsStore.useValue();\n    const currentResult = searchResults[type][pluginHash];\n    const data = currentResult?.data ?? ([] as any[]);\n\n    const search = useSearch();\n\n    useEffect(() => {\n        if (pluginHash && type && query) {\n            search(query, 1, type, pluginHash);\n        }\n    }, [pluginHash, type, query]);\n\n    return (\n        <>\n            <Condition\n                condition={\n                    currentResult?.state !== RequestStateCode.PENDING_FIRST_PAGE ||\n          !pluginHash\n                }\n                falsy={<Loading></Loading>}\n            >\n                <SwitchCase.Switch switch={type}>\n                    <SwitchCase.Case case=\"music\">\n                        <MusicResult\n                            data={data}\n                            state={currentResult?.state ?? RequestStateCode.IDLE}\n                            pluginHash={pluginHash}\n                        ></MusicResult>\n                    </SwitchCase.Case>\n                    <SwitchCase.Case case=\"album\">\n                        <AlbumResult\n                            data={data}\n                            state={currentResult?.state ?? RequestStateCode.IDLE}\n                            pluginHash={pluginHash}\n                        ></AlbumResult>\n                    </SwitchCase.Case>\n                    <SwitchCase.Case case=\"artist\">\n                        <ArtistResult\n                            data={data}\n                            state={currentResult?.state ?? RequestStateCode.IDLE}\n                            pluginHash={pluginHash}\n                        ></ArtistResult>\n                    </SwitchCase.Case>\n                    <SwitchCase.Case case=\"sheet\">\n                        <SheetResult\n                            data={data}\n                            state={currentResult?.state ?? RequestStateCode.IDLE}\n                            pluginHash={pluginHash}\n                        ></SheetResult>\n                    </SwitchCase.Case>\n                </SwitchCase.Switch>\n            </Condition>\n        </>\n    );\n}\n\nconst SearchResultBody = memo(\n    _SearchResultBody,\n    (prev, curr) => prev.pluginHash === curr.pluginHash && prev.type === curr.type,\n);\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/hooks/useSearch.ts",
    "content": "import { produce } from \"immer\";\nimport { useCallback, useRef } from \"react\";\nimport { searchResultsStore } from \"../store/search-result\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function useSearch() {\n    const searchResults = searchResultsStore.getValue();\n    const setSearchResults = searchResultsStore.setValue;\n\n    // 当前正在搜索\n    const currentQueryRef = useRef<string>(\"\");\n\n    /**\n   * query: 搜索词\n   * queryPage: 搜索页码\n   * type: 搜索类型\n   * pluginHash: 搜索条件\n   */\n    const search = useCallback(\n        async function (\n            query?: string,\n            queryPage?: number,\n            type?: IMedia.SupportMediaType,\n            pluginHash?: string,\n        ) {\n            /** 如果没有指定插件，就用所有插件搜索 */\n\n            let pluginDelegates: IPlugin.IPluginDelegate[] = [];\n\n            if (pluginHash) {\n                const tgtPlugin = PluginManager.getPluginByHash(pluginHash);\n                if (tgtPlugin) {\n                    pluginDelegates = [tgtPlugin];\n                }\n            } else {\n                pluginDelegates = PluginManager.getSupportedPlugin(\"search\");\n            }\n\n            // 使用选中插件搜素\n            pluginDelegates.forEach(async (pluginDelegate) => {\n                const _platform = pluginDelegate.platform;\n                const _hash = pluginDelegate.hash;\n\n                if (!_platform || !_hash) {\n                    // 插件无效\n                    return;\n                }\n\n                const searchType = type ?? pluginDelegate.defaultSearchType ?? \"music\";\n                console.log(\"Search: \", query, searchType, _platform);\n\n                // 上一份搜索结果\n                const prevPluginResult = searchResults[searchType][pluginDelegate.hash];\n                /** 上一份搜索还没返回/已经结束 */\n                if (\n                    (prevPluginResult?.state === RequestStateCode.PENDING_REST_PAGE ||\n                        prevPluginResult?.state === RequestStateCode.FINISHED) &&\n                    undefined === query\n                ) {\n                    return;\n                }\n\n                // 是否是一次新的搜索\n                const newSearch =\n                    (query !== undefined && query !== prevPluginResult?.query) ||\n                    prevPluginResult?.page === undefined;\n\n                // 本次搜索关键词\n                currentQueryRef.current = query =\n                    query ?? prevPluginResult?.query ?? \"\";\n\n                /** 搜索的页码 */\n                const page =\n                    queryPage ?? newSearch ? 1 : (prevPluginResult?.page ?? 0) + 1;\n\n                if (\n                    query === prevPluginResult?.query &&\n                    queryPage <= prevPluginResult?.page\n                ) {\n                    // 重复请求\n                    return;\n                }\n\n                try {\n                    setSearchResults(\n                        produce((draft) => {\n                            const prevMediaResult: any = draft[searchType];\n                            prevMediaResult[_hash] = {\n                                state: newSearch\n                                    ? RequestStateCode.PENDING_FIRST_PAGE\n                                    : RequestStateCode.PENDING_REST_PAGE,\n                                // @ts-ignore\n                                data: newSearch ? [] : prevMediaResult[_hash]?.data ?? [],\n                                query: query,\n                                page,\n                            };\n                        }),\n                    );\n                    const result = await PluginManager.callPluginDelegateMethod(\n                        pluginDelegate,\n                        \"search\",\n                        query,\n                        page,\n                        searchType,\n                    );\n                    console.log(\n                        \"SEARCH\",\n                        result,\n                        query,\n                        page,\n                        searchType,\n                        pluginDelegate.platform,\n                    );\n                    /** 如果搜索结果不是本次结果 */\n                    if (currentQueryRef.current !== query) {\n                        return;\n                    }\n                    if (!result) {\n                        throw new Error(\"搜索结果为空\");\n                    }\n                    setSearchResults(\n                        produce((draft) => {\n                            const prevMediaResult = draft[searchType];\n                            const prevPluginResult: any = prevMediaResult[_hash] ?? {\n                                data: [],\n                            };\n                            const currResult = result.data ?? [];\n\n                            prevMediaResult[_hash] = {\n                                state:\n                                    result?.isEnd === false && result?.data?.length\n                                        ? RequestStateCode.PARTLY_DONE\n                                        : RequestStateCode.FINISHED,\n                                query,\n                                page,\n                                data: newSearch\n                                    ? currResult\n                                    : (prevPluginResult.data ?? []).concat(currResult),\n                            };\n                            return draft;\n                        }),\n                    );\n                } catch (e: any) {\n                    setSearchResults(\n                        produce((draft) => {\n                            const prevMediaResult = draft[searchType];\n                            const prevPluginResult =\n                                prevMediaResult[_hash] ??\n                                ({\n                                    data: [] as any[],\n                                } as any);\n\n                            prevPluginResult.state =\n                                page === 1\n                                    ? RequestStateCode.FINISHED\n                                    : RequestStateCode.PARTLY_DONE;\n                            return draft;\n                        }),\n                    );\n                }\n            });\n        },\n        [searchResults],\n    );\n\n    return search;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/index.scss",
    "content": ".search-view-container {\n    width: 100%;\n    height: 100%;\n    padding-top: 1rem;\n    user-select: none;\n    display: flex;\n    flex-direction: column;\n    box-sizing: border-box;\n\n    & .search-header{\n        font-size: 1.4rem;\n        font-weight: 600;\n        flex-shrink: 0;\n    }\n\n    \n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/index.tsx",
    "content": "import { useEffect } from \"react\";\nimport { useMatch, useNavigate } from \"react-router-dom\";\nimport \"./index.scss\";\nimport NoPlugin from \"@/renderer/components/NoPlugin\";\nimport { Tab } from \"@headlessui/react\";\nimport { supportedMediaType } from \"@/common/constant\";\nimport { useTranslation } from \"react-i18next\";\nimport SearchResult from \"./components/SearchResult\";\nimport useSearch from \"./hooks/useSearch\";\nimport { currentMediaTypeStore, resetStore } from \"./store/search-result\";\nimport PluginManager, { useSortedSupportedPlugin } from \"@shared/plugin-manager/renderer\";\n\nexport default function SearchView() {\n    const match = useMatch(\"/main/search/:query\");\n    const query = decodeURIComponent(match?.params?.query ?? \"\");\n\n    const plugins = useSortedSupportedPlugin(\"search\");\n\n    const { t } = useTranslation();\n    const search = useSearch();\n\n    const navigate = useNavigate();\n\n    useEffect(() => {\n        if (query) {\n            const currentType = currentMediaTypeStore.getValue();\n            search(query, 1, currentType);\n        }\n    }, [query]);\n\n    useEffect(() => {\n        return () => {\n            resetStore();\n        };\n    }, []);\n\n    return (\n        <div id=\"page-container\" className=\"page-container search-view-container\">\n            <div className=\"search-header\">\n                <span className=\"highlight\">「{decodeURIComponent(query)}」</span>\n                {t(\"search_result_page.search_result_title\")}\n            </div>\n            {plugins.length ? (\n                <Tab.Group\n                    defaultIndex={history.state?.usr?.mediaIndex ?? 0}\n                    onChange={(index) => {\n                        currentMediaTypeStore.setValue(supportedMediaType[index]);\n                        // 获取history\n                        navigate(\"\", {\n                            replace: true,\n                            state: {\n                                mediaIndex: index,\n                            },\n                        });\n                    }}\n                >\n                    <Tab.List className=\"tab-list-container\">\n                        {supportedMediaType.map((type) => (\n                            <Tab key={type} as=\"div\" className=\"tab-list-item\">\n                                {t(`media.media_type_${type}`)}\n                            </Tab>\n                        ))}\n                    </Tab.List>\n                    <Tab.Panels className={\"tab-panels-container\"}>\n                        {supportedMediaType.map((type) => (\n                            <Tab.Panel className=\"tab-panel-container\" key={type}>\n                                <SearchResult\n                                    type={type}\n                                    plugins={PluginManager.getSortedSearchablePlugins(type)}\n                                    query={query}\n                                ></SearchResult>\n                            </Tab.Panel>\n                        ))}\n                    </Tab.Panels>\n                </Tab.Group>\n            ) : (\n                <NoPlugin supportMethod={t(\"plugin.method_search\")}></NoPlugin>\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/search-view/store/search-result.ts",
    "content": "/** 搜索状态 */\n\nimport { RequestStateCode } from \"@/common/constant\";\nimport Store from \"@/common/store\";\n\nexport interface ISearchResult<T extends IMedia.SupportMediaType> {\n    /** 当前页码 */\n    page?: number;\n    /** 搜索词 */\n    query?: string;\n    /** 搜索状态 */\n    state: RequestStateCode;\n    /** 数据 */\n    data: IMedia.SupportMediaItem[T][];\n}\n\ntype ISearchResults<\n    T extends keyof IMedia.SupportMediaItem = IMedia.SupportMediaType,\n> = {\n    [K in T]: Record<string, ISearchResult<K>>;\n};\n\n/** 初始值 */\nexport const initSearchResults: ISearchResults = {\n    music: {},\n    album: {},\n    artist: {},\n    sheet: {},\n    lyric: {},\n};\n\n/** key: pluginhash value: searchResult */\nconst searchResultsStore = new Store(initSearchResults);\n\nconst currentMediaTypeStore = new Store<IMedia.SupportMediaType>(\"music\");\n\nexport { searchResultsStore, currentMediaTypeStore };\n\nexport function resetStore(){\n    currentMediaTypeStore.setValue(\"music\");\n    searchResultsStore.setValue(initSearchResults);\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/CheckBoxSettingItem/index.tsx",
    "content": "import SvgAsset from \"@/renderer/components/SvgAsset\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface ICheckBoxSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n    onChange?: (event: Event, checked: boolean) => void;\n}\n\nexport default function CheckBoxSettingItem<T extends keyof IAppConfig>(\n    props: ICheckBoxSettingItemProps<T>,\n) {\n    const {\n        keyPath,\n        label,\n        onChange,\n    } = props;\n\n    const checked = useAppConfig(keyPath);\n\n    return (\n        <div className=\"setting-row\">\n            <div\n                className={classNames({\n                    \"option-item-container\": true,\n                    highlight: checked as boolean,\n                })}\n                title={label}\n                role=\"button\"\n                onClick={() => {\n                    const event = new Event(\"ConfigChanged\", {\n                        cancelable: true,\n                    });\n                    if (onChange) {\n                        onChange(event, !checked);\n                    }\n                    if (!event.defaultPrevented) {\n                        AppConfig.setConfig({\n                            [keyPath]: !checked,\n                        });\n                    }\n                }}\n            >\n                <div className=\"checkbox\">\n                    {checked ? <SvgAsset iconName=\"check\"></SvgAsset> : null}\n                </div>\n                {label}\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/ColorPickerSettingItem/index.scss",
    "content": ".picker-container {\n  margin-top: 0.6rem;\n  display: flex;\n  align-items: center;\n  column-gap: 1rem;\n\n\n  & .picker-swatch {\n    width: 1rem;\n    height: 1rem;\n    border-radius: 2px;\n    border: 1px solid var(--textColor);\n    cursor: pointer;\n    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(0, 0, 0, 0.1);\n    font-size: 1.1rem;\n  }\n}\n\n.setting-colorpicker-panel {\n  position: absolute;\n  z-index: 10;\n\n\n  & .react-colorful__pointer {\n    width: 14px;\n    height: 14px;\n  }\n\n  & .react-colorful__hue,\n  & .react-colorful__alpha {\n    height: 18px;\n    border-radius: 0;\n  }\n\n  & .setting-colorpicker-options {\n    width: 200px;\n    display: flex;\n    & input {\n      letter-spacing: 1px;\n      width: 136px;\n      background: var(--backdropColor, var(--backgroundColor))\n    }\n\n    & div[role=\"button\"] {\n      width: 64px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      background-color: var(--primaryColor);\n      color: white;\n\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/ColorPickerSettingItem/index.tsx",
    "content": "import { Popover } from \"@headlessui/react\";\nimport \"./index.scss\";\nimport { useState } from \"react\";\nimport { HexAlphaColorPicker, HexColorInput } from \"react-colorful\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface IColorPickerSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n}\n\nexport default function ColorPickerSettingItem<T extends keyof IAppConfig>(\n    props: IColorPickerSettingItemProps<T>,\n) {\n    const { keyPath, label } = props;\n    const realColor = useAppConfig(keyPath);\n    const [color, setColor] = useState<string>(realColor as string);\n\n    return (\n        <Popover className=\"setting-row\">\n            <div className=\"label-container\">{label}</div>\n            <div className=\"picker-container\">\n                <Popover.Button\n                    as=\"div\"\n                    style={{\n                        backgroundColor: realColor as string,\n                    }}\n                    className={\"picker-swatch\"}\n                ></Popover.Button>\n                <div>{realColor as string}</div>\n            </div>\n            <Popover.Panel className={\"setting-colorpicker-panel shadow\"}>\n                {({ close }) => {\n                    return (\n                        <>\n                            <HexAlphaColorPicker\n                                color={color}\n                                onChange={setColor}\n                            ></HexAlphaColorPicker>\n                            <div className=\"setting-colorpicker-options\">\n                                <HexColorInput\n                                    color={color}\n                                    onChange={setColor}\n                                    alpha\n                                    prefixed\n                                    placeholder=\"选择一个颜色\"\n                                ></HexColorInput>\n                                <div\n                                    role=\"button\"\n                                    onClick={() => {\n                                        AppConfig.setConfig({\n                                            [keyPath]: color as any,\n                                        });\n                                        close();\n                                    }}\n                                >\n                                    提交\n                                </div>\n                            </div>\n                        </>\n                    );\n                }}\n            </Popover.Panel>\n        </Popover>\n        // <div\n        //   className=\"setting-row\"\n        //   role=\"button\"\n        //   onClick={() => {\n        //     setAppConfigPath(keyPath, !checked as any);\n        //   }}\n        // >\n        //   <div\n        //     className={classNames({\n        //       \"option-item-container\": true,\n        //       highlight: checked as boolean,\n        //     })}\n        //     title={label}\n        //   >\n        //     <div className=\"checkbox\">\n        //       {checked ? <SvgAsset iconName=\"check\"></SvgAsset> : null}\n        //     </div>\n        //     {label}\n        //   </div>\n        // </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/FontPickerSettingItem/index.scss",
    "content": ".setting-view--list-box-setting-item-container {\n  & .options-container {\n    margin-top: 0.6rem;\n    display: flex;\n    gap: 0.5rem 3rem;\n    position: relative;\n    user-select: none;\n\n    & .listbox-button {\n      height: 2.6rem;\n      width: 200px;\n      position: relative;\n      display: flex;\n      align-items: center;\n      padding-left: 12px;\n      border-radius: 6px;\n      background-color: var(--placeholderColor);\n      border: 1px solid var(--dividerColor);\n\n      &::after {\n        position: absolute;\n        right: 6px;\n        content: \"\";\n        width: 0;\n        height: 0;\n        margin-left: 0.5rem;\n        border: 4px solid  transparent;\n        border-left-color: currentColor;\n        transform-origin: left center;\n        transform: rotate(90deg);\n      }\n    }\n\n    & .listbox-options {\n      position: absolute;\n      padding-inline-start: 0;\n      margin-block-start: 0;\n      border-radius: 4px;\n      left: 0;\n      top: 3rem;\n      z-index: 10;\n      width: 212px;\n      height: 280px;\n      overflow-y: auto;\n      list-style: none;\n\n      & .listbox-option {\n        list-style: none;\n        height: 2.2rem;\n        line-height: 2.2rem;\n        padding-left: 12px;\n        vertical-align: middle;\n        cursor: default;\n\n        &[data-headlessui-state*=\"active\"] {\n          color: var(--primaryColor);\n        }\n      }\n\n      &:focus {\n        outline: none;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/FontPickerSettingItem/index.tsx",
    "content": "import { useMemo } from \"react\";\nimport ListBoxSettingItem from \"../ListBoxSettingItem\";\nimport { defaultFont as _defaultFont } from \"@/common/constant\";\nimport useLocalFonts from \"@/hooks/useLocalFonts\";\nimport { useTranslation } from \"react-i18next\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface FontPickerSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n}\n\nfunction useFonts() {\n    const allLocalFonts = useLocalFonts();\n    const { t } = useTranslation();\n\n    const defaultFont = {\n        ..._defaultFont,\n        fullName: t(\"common.default\"),\n    };\n\n    const fonts = useMemo(\n        () => (allLocalFonts ? [defaultFont, ...allLocalFonts] : null),\n        [allLocalFonts],\n    );\n\n    return fonts;\n}\n\nexport default function FontPickerSettingItem<T extends keyof IAppConfig>(\n    props: FontPickerSettingItemProps<T>,\n) {\n    const { keyPath, label } = props;\n\n    const fonts = useFonts();\n    return (\n        <ListBoxSettingItem\n            label={label}\n            keyPath={keyPath}\n            renderItem={(item) => (item as FontData).fullName}\n            options={fonts ?? (null as any)}\n            onChange={(event, newValue) => {\n                // 字体不可序列化 不知道为啥 json.stringify是空对象\n                event.preventDefault();\n                console.log(event.defaultPrevented, \"Prev\");\n                AppConfig.setConfig({\n                    [keyPath]: {\n                        family: (newValue as FontData).family,\n                        fullName: (newValue as FontData).fullName,\n                        postscriptName: (newValue as FontData).postscriptName,\n                        style: (newValue as FontData).style,\n                    },\n                });\n            }}\n        ></ListBoxSettingItem>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/InputSettingItem/index.scss",
    "content": ".setting-view--input-setting-item-container {\n  display: flex;\n  align-items: center;\n  width: 200px;\n  column-gap: 12px;\n\n  & .input-label {\n    width: 48px;\n    white-space: nowrap;\n  }\n\n  & input {\n    flex: 1;\n  }\n\n  & input:disabled {\n    opacity: 0.5;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/InputSettingItem/index.tsx",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\nimport \"./index.scss\";\nimport { HTMLInputTypeAttribute, useState } from \"react\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\n\ninterface InputSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n    onChange?: (event: Event, val: IAppConfig[T]) => void;\n    width?: number | string;\n    /** 是否过滤首尾空格 */\n    trim?: boolean;\n    disabled?: boolean;\n    type?: HTMLInputTypeAttribute;\n}\n\nexport default function InputSettingItem<T extends keyof IAppConfig>(\n    props: InputSettingItemProps<T>,\n) {\n    const {\n        keyPath,\n        label,\n        onChange,\n        width,\n        type,\n        disabled,\n        trim,\n    } = props;\n\n    const value = useAppConfig(keyPath);\n    const [tmpValue, setTmpValue] = useState<string | null>(value as string || \"\");\n\n    return (\n        <div\n            className=\"setting-view--input-setting-item-container\"\n            style={{\n                width,\n            }}\n        >\n            {label ? <div className=\"input-label\">{label}</div> : null}\n            <input\n                disabled={disabled}\n                spellCheck={false}\n                onChange={(e) => {\n                    setTmpValue(e.target.value ?? null);\n                }}\n                type={type}\n                onBlur={() => {\n                    if (tmpValue === null) {\n                        return;\n                    }\n                    const event = new Event(\"ConfigChanged\", {\n                        cancelable: true,\n                    });\n\n                    if (onChange) {\n                        onChange(event, tmpValue as any);\n                    }\n\n                    if (!event.defaultPrevented) {\n                        console.log(tmpValue);\n                        AppConfig.setConfig({\n                            [keyPath]: trim ? tmpValue.trim() as any : tmpValue as any,\n                        });\n                    }\n                }}\n                defaultValue={value as string}\n                value={(tmpValue || \"\") as string}\n            ></input>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/ListBoxSettingItem/index.scss",
    "content": ".setting-view--list-box-setting-item-container {\n  .question-mark-container {\n    $size: 1.1rem;\n    display: inline-block;\n    margin-left: 0.5rem;\n    width: $size;\n    height: 100%;\n    text-align: center;\n    vertical-align: middle;\n\n    & svg {\n      width: $size;\n      height: $size;\n      cursor: pointer;\n    }\n  }\n\n  & .options-container {\n    margin-top: 0.6rem;\n    display: flex;\n    gap: 0.5rem 3rem;\n    position: relative;\n    user-select: none;\n    $item-width: 220px;\n\n    & .listbox-button {\n      height: 2.6rem;\n      width: $item-width;\n      position: relative;\n      display: flex;\n      align-items: center;\n      box-sizing: border-box;\n      padding-left: 12px;\n      padding-right: 24px;\n      border-radius: 6px;\n      background-color: var(--placeholderColor);\n      border: 1px solid var(--dividerColor);\n\n      & span {\n        white-space: nowrap;\n        overflow: hidden;\n        text-overflow: ellipsis;\n      }\n\n      &::after {\n        position: absolute;\n        right: 6px;\n        content: \"\";\n        width: 0;\n        height: 0;\n        margin-left: 0.5rem;\n        border: 4px solid transparent;\n        border-left-color: currentColor;\n        transform-origin: left center;\n        transform: rotate(90deg);\n      }\n    }\n\n    & .listbox-options {\n      position: absolute;\n      padding-inline-start: 0;\n      margin-block-start: 0;\n      border-radius: 4px;\n      left: 0;\n      top: 3rem;\n      z-index: 10;\n      width: $item-width;\n      max-height: 280px;\n      overflow-x: hidden;\n      overflow-y: auto;\n      list-style: none;\n\n      & .listbox-option {\n        list-style: none;\n        height: 2.2rem;\n        width: $item-width;\n        line-height: 2.2rem;\n        padding: 0 12px;\n        vertical-align: middle;\n        cursor: default;\n        box-sizing: border-box;\n\n        &[data-headlessui-state*=\"active\"] {\n          color: var(--primaryColor);\n        }\n\n        & div {\n          overflow-x: hidden;\n          white-space: nowrap;\n          text-overflow: ellipsis;\n        }\n      }\n\n      &:focus {\n        outline: none;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/ListBoxSettingItem/index.tsx",
    "content": "import { Listbox } from \"@headlessui/react\";\nimport \"./index.scss\";\nimport Condition, { IfTruthy } from \"@/renderer/components/Condition\";\nimport Loading from \"@/renderer/components/Loading\";\nimport { isBasicType } from \"@/common/normalize-util\";\nimport useVirtualList from \"@/hooks/useVirtualList\";\nimport { rem } from \"@/common/constant\";\nimport { ReactNode, useRef } from \"react\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { Tooltip } from \"react-tooltip\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ninterface ListBoxSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n    options: Array<IAppConfig[T]> | null;\n    onChange?: (event: Event, newConfig: IAppConfig[T]) => void;\n    renderItem?: (item: IAppConfig[T]) => ReactNode;\n    width?: number | string;\n    toolTip?: string;\n}\n\nexport default function ListBoxSettingItem<T extends keyof IAppConfig>(\n    props: ListBoxSettingItemProps<T>,\n) {\n\n    const {\n        keyPath,\n        label,\n        options,\n        onChange,\n        renderItem,\n        width,\n        toolTip,\n    } = props;\n\n    const value = useAppConfig(keyPath);\n\n    return (\n        <div className=\"setting-view--list-box-setting-item-container setting-row\">\n            <IfTruthy condition={toolTip}>\n                <Tooltip id={`tt-${keyPath}`}></Tooltip>\n            </IfTruthy>\n            <Listbox\n                value={value}\n                onChange={\n                    (newVal) => {\n                        const event = new Event(\"ConfigChanged\", {\n                            cancelable: true,\n                        });\n                        if (onChange) {\n                            onChange(event, newVal);\n                        }\n                        if (!event.defaultPrevented) {\n                            AppConfig.setConfig({\n                                [keyPath]: newVal,\n                            });\n                        }\n                    }\n                }\n            >\n                <div className={\"label-container\"}>\n                    {label}\n                    <IfTruthy condition={toolTip}>\n                        <div\n                            className=\"question-mark-container\"\n                            data-tooltip-id={`tt-${keyPath}`}\n                            data-tooltip-content={toolTip}\n                        >\n                            <SvgAsset iconName=\"question-mark-circle\"></SvgAsset>\n                        </div>\n                    </IfTruthy>\n                </div>\n                <div className=\"options-container\">\n                    <Listbox.Button\n                        as=\"div\"\n                        className={\"listbox-button\"}\n                        style={{ width }}\n                    >\n                        <span>\n                            {renderItem\n                                ? renderItem(value)\n                                : isBasicType(value)\n                                    ? (value as string)\n                                    : \"\"}\n                        </span>\n                    </Listbox.Button>\n                    <Listbox.Options as={\"div\"}>\n                        <ListBoxOptions\n                            width={width}\n                            options={options}\n                            renderItem={renderItem}\n                        ></ListBoxOptions>\n                    </Listbox.Options>\n                </div>\n            </Listbox>\n        </div>\n    );\n}\n\ninterface IListBoxOptionsProps<T extends keyof IAppConfig> {\n    options: Array<IAppConfig[T]> | null;\n    renderItem?: (item: IAppConfig[T]) => ReactNode;\n    width?: number | string;\n}\n\nfunction ListBoxOptions<T extends keyof IAppConfig>(\n    props: IListBoxOptionsProps<T>,\n) {\n    const { options, renderItem, width } = props;\n    const containerRef = useRef<HTMLDivElement>();\n\n    const virtualController = useVirtualList({\n        data: options ?? [],\n        estimateItemHeight: 2.2 * rem,\n        getScrollElement: () => containerRef.current,\n        renderCount: 40,\n        fallbackRenderCount: 20,\n    });\n\n    return (\n        <div\n            ref={containerRef}\n            className={\"listbox-options shadow backdrop-color\"}\n            style={{ width }}\n        >\n            <Condition condition={options !== null} falsy={<Loading></Loading>}>\n                <div\n                    style={{\n                        position: \"relative\",\n                        height: virtualController.totalHeight,\n                    }}\n                >\n                    {virtualController.virtualItems?.map?.((virtualItem) => (\n                        <Listbox.Option\n                            className={\"listbox-option\"}\n                            key={virtualItem.rowIndex}\n                            value={virtualItem.dataItem}\n                            style={{\n                                position: \"absolute\",\n                                top: virtualItem.top,\n                                width,\n                            }}\n                            as=\"div\"\n                        >\n                            <div>\n                                {renderItem\n                                    ? renderItem(virtualItem.dataItem)\n                                    : isBasicType(virtualItem.dataItem)\n                                        ? (virtualItem.dataItem as string)\n                                        : \"\"}\n                            </div>\n                        </Listbox.Option>\n                    ))}\n                </div>\n            </Condition>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/MultiRadioGroupSettingItem/index.scss",
    "content": ".setting-view--radio-group-setting-item-container {\n  \n\n  & .options-container {\n    margin-top: 0.6rem;\n    display: flex;\n    gap: 0.5rem 3rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/MultiRadioGroupSettingItem/index.tsx",
    "content": "import \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\ntype ExtractArrayItem<T> = T extends Array<infer R> ? R : never;\n\ninterface IRadioGroupSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n    options: IAppConfig[T];\n    renderItem?: (item: ExtractArrayItem<IAppConfig[T]>) => string;\n    direction?: \"horizontal\" | \"vertical\";\n}\n\n/**\n * 多选\n * @param props\n * @constructor\n */\nexport default function MultiRadioGroupSettingItem<T extends keyof IAppConfig>(\n    props: IRadioGroupSettingItemProps<T>,\n) {\n    const {\n        keyPath,\n        label,\n        options,\n        renderItem,\n        direction = \"horizontal\",\n    } = props;\n    const value = useAppConfig(keyPath);\n\n\n    return (\n        <div className=\"setting-view--radio-group-setting-item-container setting-row\">\n            <div className={\"label-container\"}>{label}</div>\n            <div\n                className=\"options-container\"\n                style={{\n                    flexDirection: direction === \"horizontal\" ? \"row\" : \"column\",\n                }}\n            >\n                {(options as any[]).map((option, index) => {\n                    const checked = (value as Array<any>)?.includes(option);\n                    const title = renderItem ? renderItem(option) : (option as string);\n\n                    return (\n                        <div\n                            className={classNames({\n                                \"option-item-container\": true,\n                                highlight: checked,\n                            })}\n                            title={title}\n                            key={index}\n                            onClick={() => {\n                                let newValue = [];\n                                if (checked) {\n                                    newValue = (value as Array<any>)?.filter(\n                                        (it) => it !== option,\n                                    ) ?? [];\n\n                                } else {\n                                    newValue = [...(value as Array<any> || []), option];\n\n                                }\n                                AppConfig.setConfig({\n                                    [keyPath]: newValue,\n                                });\n                            }}\n                        >\n                            <div className=\"checkbox\">\n                                {checked ? <SvgAsset iconName=\"check\"></SvgAsset> : null}\n                            </div>\n                            {title}\n                        </div>\n                    );\n                })}\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/PathSettingItem/index.scss",
    "content": ".setting-view--path-setting-item-container {\n  & .options-container {\n    margin-top: 0.6rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem 1rem;\n    position: relative;\n\n    & .path-container {\n      max-width: 60%;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      margin-right: 2rem;\n    }\n\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/PathSettingItem/index.tsx",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\nimport \"./index.scss\";\nimport { toast } from \"react-toastify\";\nimport { useTranslation } from \"react-i18next\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { dialogUtil, fsUtil, shellUtil } from \"@shared/utils/renderer\";\n\ninterface PathSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n}\n\nexport default function PathSettingItem<T extends keyof IAppConfig>(\n    props: PathSettingItemProps<T>,\n) {\n    const { keyPath, label } = props;\n    const value = useAppConfig(keyPath);\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--path-setting-item-container setting-row\">\n            <div className=\"label-container\">{label}</div>\n            <div className=\"options-container\">\n                <span className=\"path-container\" title={value as string}>\n                    {value as string}\n                </span>\n                <div\n                    role=\"button\"\n                    data-type=\"primaryButton\"\n                    onClick={async () => {\n                        const result = await dialogUtil.showOpenDialog({\n                            title: t(\"settings.choose_path\"),\n                            defaultPath: value as string,\n                            properties: [\"openDirectory\"],\n                            buttonLabel: t(\"common.confirm\"),\n                        });\n                        if (!result.canceled) {\n                            AppConfig.setConfig({\n                                [keyPath]: result.filePaths[0]! as any,\n                            });\n                        }\n                    }}\n                >\n                    {t(\"settings.change_path\")}\n                </div>\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={async () => {\n                        if (await fsUtil.isFolder(value as string)) {\n                            shellUtil.openPath(value as string);\n                        } else {\n                            toast.error(t(\"settings.folder_not_exist\"));\n                        }\n                    }}\n                >\n                    {t(\"settings.open_folder\")}\n                </div>\n            </div>\n            {/* </Listbox> */}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/RadioGroupSettingItem/index.scss",
    "content": ".setting-view--radio-group-setting-item-container {\n  \n\n  & .options-container {\n    margin-top: 0.6rem;\n    display: flex;\n    gap: 0.5rem 3rem;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/components/RadioGroupSettingItem/index.tsx",
    "content": "import { RadioGroup } from \"@headlessui/react\";\nimport \"./index.scss\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\n\ninterface IRadioGroupSettingItemProps<T extends keyof IAppConfig> {\n    keyPath: T;\n    label?: string;\n    options: Array<IAppConfig[T]>\n    renderItem?: (item: IAppConfig[T]) => string;\n    direction?: \"horizontal\" | \"vertical\";\n}\n\nexport default function RadioGroupSettingItem<T extends keyof IAppConfig>(\n    props: IRadioGroupSettingItemProps<T>,\n) {\n    const {\n        keyPath,\n        label,\n        options,\n        direction = \"horizontal\",\n        renderItem,\n    } = props;\n\n    const value = useAppConfig(keyPath);\n\n    return (\n        <div className=\"setting-view--radio-group-setting-item-container setting-row\">\n            <RadioGroup\n                value={value}\n                onChange={(val) => {\n                    AppConfig.setConfig({\n                        [keyPath]: val,\n                    });\n                }}\n            >\n                <RadioGroup.Label className={\"label-container\"}>\n                    {label}\n                </RadioGroup.Label>\n                <div\n                    className=\"options-container\"\n                    style={{\n                        flexDirection: direction === \"horizontal\" ? \"row\" : \"column\",\n                    }}\n                >\n                    {options.map((option, index) => (\n                        <RadioGroup.Option key={index} value={option}>\n                            {({ checked }) => {\n                                const title = renderItem ? renderItem(option) : option as string;\n                                return (\n                                    <div\n                                        className={classNames({\n                                            \"option-item-container\": true,\n                                            highlight: checked,\n                                        })}\n                                        title={title}\n                                    >\n                                        <div className=\"checkbox\">\n                                            {checked ? <SvgAsset iconName=\"check\"></SvgAsset> : null}\n                                        </div>\n                                        {title}\n                                    </div>\n                                );\n                            }}\n                        </RadioGroup.Option>\n                    ))}\n                </div>\n            </RadioGroup>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/index.scss",
    "content": ".setting-view--container {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n\n  & .setting-view--header {\n    width: 100%;\n    flex-shrink: 0;\n    padding-left: 1.5rem;\n    padding-right: 1.5rem;\n    box-sizing: border-box;\n\n    & .tab-list-container {\n      overflow-x: auto;\n    }\n  }\n\n  & .setting-view--body {\n    flex: 1;\n    overflow-y: auto;\n    margin-top: 1rem;\n    padding-bottom: 1rem;\n    height: max-content;\n    padding-left: 1.5rem;\n    padding-right: 1.5rem;\n\n    & .setting-view--body-item-container {\n      width: 100%;\n\n      & .setting-view--body-title {\n        font-size: 1.1rem;\n        font-weight: 600;\n        margin-top: 1rem;\n        margin-bottom: 1rem;\n      }\n    }\n  }\n\n  & .setting-row {\n    width: 100%;\n    position: relative;\n    margin-top: 1.2rem;\n    margin-bottom: 1.2rem;\n\n    & .option-item-container {\n      display: flex;\n      align-items: center;\n      cursor: pointer;\n      font-size: 1.1rem;\n      width: fit-content;\n\n      & .checkbox {\n        width: 1rem;\n        height: 1rem;\n        border-radius: 2px;\n        border: 1px solid currentColor;\n        margin-right: 0.4rem;\n        position: relative;\n\n        & svg {\n          position: absolute;\n          width: 1rem;\n          height: 1rem;\n          left: 0;\n          top: 0;\n        }\n      }\n    }\n  }\n\n  & .label-container {\n    opacity: 0.6;\n    font-size: 1rem;\n    font-weight: 600;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/index.tsx",
    "content": "import \"./index.scss\";\nimport routers from \"./routers\";\nimport { useEffect, useRef, useState } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport { useTranslation } from \"react-i18next\";\nimport camelToSnake from \"@/common/camel-to-snake\";\n\nexport default function SettingView() {\n    const [selected, setSelected] = useState(routers[0].id);\n    const { t } = useTranslation();\n\n    const intersectionObserverRef = useRef<IntersectionObserver>();\n    const bodyContainerRef = useRef<HTMLDivElement>();\n    const intersectionRatioRef = useRef<Map<string, number>>(new Map());\n\n    useEffect(() => {\n        intersectionObserverRef.current = new IntersectionObserver(\n            (targets) => {\n                const ratio = intersectionRatioRef.current;\n                targets.forEach((target) => {\n                    ratio.set(target.target.id, target.intersectionRatio);\n                });\n                let maxVal = 0;\n                let maxId;\n                for (const entry of ratio.entries()) {\n                    if (entry[1] > maxVal) {\n                        maxId = entry[0];\n                        maxVal = entry[1];\n                    }\n                }\n                setSelected(maxId.slice(8));\n            },\n            {\n                root: bodyContainerRef.current,\n                threshold: [0, 0.2, 0.8, 1],\n            },\n        );\n\n        for (const setting of routers) {\n            const target = document.getElementById(`setting-${setting.id}`);\n            if (target) {\n                intersectionObserverRef.current.observe(target);\n            }\n        }\n        return () => {\n            document\n                .getElementById(\"page-container\")\n                ?.classList?.remove(\"page-container-full-width\");\n\n            intersectionObserverRef.current.disconnect();\n            intersectionObserverRef.current = null;\n            intersectionRatioRef.current.clear();\n            intersectionRatioRef.current = null;\n        };\n    }, []);\n\n    return (\n        <div\n            id=\"page-container\"\n            className=\"page-container-fw setting-view--container\"\n        >\n            <div className=\"setting-view--header\">\n                <div className=\"tab-list-container\">\n                    {routers.map((setting) => (\n                        <div\n                            key={setting.id}\n                            className=\"tab-list-item\"\n                            data-headlessui-state={\n                                selected === setting.id ? \"selected\" : null\n                            }\n                            role=\"button\"\n                            onClick={() => {\n                                document\n                                    .getElementById(`setting-${setting.id}`)\n                                    ?.scrollIntoView({\n                                        behavior: \"smooth\",\n                                    });\n                            }}\n                        >\n                            {t(`settings.section_name.${camelToSnake(setting.id)}`)}\n                        </div>\n                    ))}\n                </div>\n            </div>\n            <div className=\"setting-view--body\" ref={bodyContainerRef}>\n                {routers.map((setting, index) => {\n                    const Component = setting.component as any;\n\n                    return (\n                        <div\n                            className=\"setting-view--body-item-container\"\n                            id={`setting-${setting.id}`}\n                            key={setting.id}\n                        >\n                            <div className=\"setting-view--body-title\">\n                                {t(`settings.section_name.${camelToSnake(setting.id)}`)}\n                            </div>\n                            <Component></Component>\n                            <Condition condition={index !== routers.length - 1}>\n                                <div className=\"divider\"></div>\n                            </Condition>\n                        </div>\n                    );\n                })}\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/About/index.scss",
    "content": ".setting-view--about-container{\n    & .about-version {\n        display: flex;\n        align-items: center;\n        column-gap: 16px;\n\n    }\n\n    & .wx-channel {\n        height: 150px;\n    }\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/About/index.tsx",
    "content": "import A from \"@/renderer/components/A\";\nimport wxChannelImg from \"@/assets/imgs/wechat_channel1.png\";\nimport checkUpdate from \"@/renderer/utils/check-update\";\nimport { toast } from \"react-toastify\";\nimport \"./index.scss\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { getGlobalContext } from \"@/shared/global-context/renderer\";\n\nexport default function About() {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--about-container\">\n            <div className=\"setting-row about-version\">\n                <Trans\n                    i18nKey={\"settings.about.current_version\"}\n                    values={{\n                        version: getGlobalContext().appVersion,\n                    }}\n                ></Trans>\n                <A\n                    onClick={async () => {\n                        const needUpdate = await checkUpdate(true);\n                        if (!needUpdate) {\n                            toast.success(t(\"settings.about.already_latest\"));\n                        }\n                    }}\n                >\n                    {t(\"settings.about.check_update\")}\n                </A>\n            </div>\n\n            <div className=\"setting-row about-version\">\n                {t(\"settings.about.software_author\")}{\" \"}\n                <A href=\"https://github.com/maotoumao\">Github@猫头猫</A>\n                <A href=\"https://space.bilibili.com/12866223\">\n                    bilibili@不想睡觉猫头猫\n                </A>\n                <A href=\"https://twitter.com/upupfun\">X@upupfun</A>\n            </div>\n            <img className=\"wx-channel\" src={wxChannelImg}></img>\n\n            <div className=\"setting-row about-version\">\n                <Trans\n                    i18nKey=\"settings.about.open_source_declaration\"\n                    components={{\n                        Github: (\n                            <A href=\"https://github.com/maotoumao/MusicFreeDesktop\"></A>\n                        ),\n                        Gitee: <A href=\"https://gitee.com/maotoumao/MusicFreeDesktop\"></A>,\n                    }}\n                ></Trans>\n            </div>\n            <div className=\"setting-row about-version\">\n                <A href=\"http://musicfree.catcat.work/\">\n                    {t(\"settings.about.official_site\")}\n                </A>\n                <A href=\"https://github.com/maotoumao/MusicFree\">\n                    {t(\"settings.about.mobile_version\")}\n                </A>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Backup/index.scss",
    "content": ".setting-view--backup-container {\n  width: 100%;\n\n  & .backup-row {\n    display: flex;\n    gap: 12px;\n  }\n\n  & .webdav-backup-container {\n    display: grid;\n    grid-template-columns: 33% 33%;\n    gap: 18px;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Backup/index.tsx",
    "content": "import \"./index.scss\";\nimport MusicSheet from \"@/renderer/core/music-sheet\";\nimport { toast } from \"react-toastify\";\nimport RadioGroupSettingItem from \"../../components/RadioGroupSettingItem\";\nimport InputSettingItem from \"../../components/InputSettingItem\";\nimport { AuthType, createClient } from \"webdav\";\nimport BackupResume from \"@/renderer/core/backup-resume\";\nimport { useTranslation } from \"react-i18next\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport { dialogUtil, fsUtil } from \"@shared/utils/renderer\";\n\n\n\nexport default function Backup() {\n    const { t } = useTranslation();\n\n\n    async function onBackupClick() {\n        const url = AppConfig.getConfig(\"backup.webdav.url\");\n        const username = AppConfig.getConfig(\"backup.webdav.username\");\n        const password = AppConfig.getConfig(\"backup.webdav.password\");\n\n        try {\n            if (\n                url && username && password\n            ) {\n                const client = createClient(url, {\n                    authType: AuthType.Password,\n                    username: username,\n                    password: password,\n                });\n                const sheetDetails =\n                    await MusicSheet.frontend.exportAllSheetDetails();\n                const backUp = JSON.stringify(\n                    {\n                        musicSheets: sheetDetails,\n                    },\n                    undefined,\n                    0,\n                );\n                if (!(await client.exists(\"/MusicFree\"))) {\n                    await client.createDirectory(\"/MusicFree\");\n                }\n                // 临时文件\n                await client.putFileContents(\n                    \"/MusicFree/MusicFreeBackup.json\",\n                    backUp,\n                    {\n                        overwrite: true,\n                    },\n                );\n                toast.success(t(\"settings.backup.backup_success\"));\n            } else {\n                toast.error(t(\"settings.backup.webdav_data_not_complete\"));\n            }\n        } catch (e) {\n            toast.error(\n                t(\"settings.backup.backup_fail\", {\n                    reason: e?.message,\n                }),\n            );\n        }\n    }\n\n    async function onResumeClick() {\n        const url = AppConfig.getConfig(\"backup.webdav.url\");\n        const username = AppConfig.getConfig(\"backup.webdav.username\");\n        const password = AppConfig.getConfig(\"backup.webdav.password\");\n        try {\n            if (\n                url &&\n                username &&\n                password\n            ) {\n                const client = createClient(url, {\n                    authType: AuthType.Password,\n                    username: username,\n                    password: password,\n                });\n\n                if (!(await client.exists(\"/MusicFree/MusicFreeBackup.json\"))) {\n                    throw new Error(\n                        t(\"settings.backup.webdav_backup_file_not_exist\"),\n                    );\n                }\n                const resumeData = await client.getFileContents(\n                    \"/MusicFree/MusicFreeBackup.json\",\n                    {\n                        format: \"text\",\n                    },\n                );\n                await BackupResume.resume(\n                    resumeData,\n                    AppConfig.getConfig(\"backup.resumeBehavior\") === \"overwrite\",\n                );\n                toast.success(t(\"settings.backup.resume_success\"));\n            } else {\n                toast.error(t(\"settings.backup.webdav_data_not_complete\"));\n            }\n        } catch (e) {\n            toast.error(\n                t(\"settings.backup.resume_fail\", {\n                    reason: e?.message,\n                }),\n            );\n        }\n\n    }\n\n    return (\n        <div className=\"setting-view--backup-container\">\n            <RadioGroupSettingItem\n                keyPath=\"backup.resumeBehavior\"\n                options={[\n                    \"append\",\n                    \"overwrite\",\n                ]}\n                renderItem={(item) => t(\"settings.backup.resume_mode_\" + item)}\n            ></RadioGroupSettingItem>\n            <div className={\"label-container\"}>\n                {t(\"settings.backup.backup_by_file\")}\n            </div>\n            <div className=\"setting-row backup-row\">\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={async () => {\n                        const result = await dialogUtil.showSaveDialog({\n                            properties: [\"showOverwriteConfirmation\", \"createDirectory\"],\n                            filters: [\n                                {\n                                    name: t(\"settings.backup.musicfree_backup_file\"),\n                                    extensions: [\"json\", \"txt\"],\n                                },\n                            ],\n                            title: t(\"settings.backup.backup_to\"),\n                        });\n                        if (!result.canceled && result.filePath) {\n                            const sheetDetails =\n                                await MusicSheet.frontend.exportAllSheetDetails();\n                            const backUp = JSON.stringify({\n                                musicSheets: sheetDetails,\n                            });\n                            await fsUtil.writeFile(result.filePath, backUp, \"utf-8\");\n                            toast.success(t(\"settings.backup.backup_success\"));\n                        }\n                    }}\n                >\n                    {t(\"settings.backup.backup_music_sheet\")}\n                </div>\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={async () => {\n                        const result = await dialogUtil.showOpenDialog({\n                            properties: [\"openFile\"],\n                            filters: [\n                                {\n                                    name: t(\"settings.backup.musicfree_backup_file\"),\n                                    extensions: [\"json\", \"txt\"],\n                                },\n                            ],\n                            title: t(\"common.open\"),\n                        });\n                        if (!result.canceled && result.filePaths) {\n                            try {\n                                const rawSheets = (await fsUtil.readFile(\n                                    result.filePaths[0],\n                                    \"utf-8\",\n                                )) as string;\n\n                                await BackupResume.resume(\n                                    rawSheets,\n                                    AppConfig.getConfig(\"backup.resumeBehavior\") === \"overwrite\",\n                                );\n\n                                toast.success(t(\"backup.backup_success\"));\n                            } catch (e) {\n                                toast.error(\n                                    t(\"backup.backup_fail\", {\n                                        reason: e?.message,\n                                    }),\n                                );\n                            }\n                        }\n                    }}\n                >\n                    {t(\"settings.backup.resume_music_sheet\")}\n                </div>\n            </div>\n            <div className={\"label-container setting-row\"}>\n                {t(\"settings.backup.backup_by_webdav\")}\n            </div>\n            <div className=\"webdav-backup-container\">\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.backup.webdav_server_url\")}\n                    trim\n                    keyPath=\"backup.webdav.url\"\n                ></InputSettingItem>\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.backup.username\")}\n                    trim\n                    keyPath=\"backup.webdav.username\"\n                ></InputSettingItem>\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.backup.password\")}\n                    type=\"password\"\n                    trim\n                    keyPath=\"backup.webdav.password\"\n                ></InputSettingItem>\n            </div>\n            <div className=\"setting-row backup-row\">\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={onBackupClick}\n                >\n                    {t(\"settings.backup.backup_music_sheet\")}\n                </div>\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={onResumeClick}\n                >\n                    {t(\"settings.backup.resume_music_sheet\")}\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Download/index.scss",
    "content": ".setting-view--download-container {\n  width: 100%;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Download/index.tsx",
    "content": "import \"./index.scss\";\nimport RadioGroupSettingItem from \"../../components/RadioGroupSettingItem\";\nimport ListBoxSettingItem from \"../../components/ListBoxSettingItem\";\nimport Downloader from \"@/renderer/core/downloader\";\nimport PathSettingItem from \"../../components/PathSettingItem\";\nimport { useTranslation } from \"react-i18next\";\n\n\nconst concurrencyList = Array(20)\n    .fill(0)\n    .map((_, index) => index + 1);\n\n\nexport default function Download() {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--download-container\">\n            <PathSettingItem\n                keyPath=\"download.path\"\n                label={t(\"settings.download.download_folder\")}\n            ></PathSettingItem>\n            <ListBoxSettingItem\n                keyPath=\"download.concurrency\"\n                options={concurrencyList}\n                onChange={(_evt, newConfig) => {\n                    Downloader.setDownloadingConcurrency(newConfig);\n                }}\n                label={t(\"settings.download.max_concurrency\")}\n            ></ListBoxSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.download.default_download_quality\")}\n                keyPath=\"download.defaultQuality\"\n                options={[\n                    \"low\",\n                    \"standard\",\n                    \"high\",\n                    \"super\",\n                ]}\n                renderItem={(item) => t(\"media.music_quality_\" + item)}\n            ></RadioGroupSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.download.when_quality_missing\")}\n                keyPath=\"download.whenQualityMissing\"\n                options={[\n                    \"lower\",\n                    \"higher\",\n                ]}\n                renderItem={(item) => t(\"settings.download.download_\" + item + \"_quality_version\")}\n\n            ></RadioGroupSettingItem>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Lyric/index.scss",
    "content": ""
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Lyric/index.tsx",
    "content": "import CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport \"./index.scss\";\nimport ColorPickerSettingItem from \"../../components/ColorPickerSettingItem\";\nimport ListBoxSettingItem from \"../../components/ListBoxSettingItem\";\nimport FontPickerSettingItem from \"../../components/FontPickerSettingItem\";\nimport { IfTruthy } from \"@/renderer/components/Condition\";\nimport { useTranslation } from \"react-i18next\";\nimport { getGlobalContext } from \"@/shared/global-context/renderer\";\nimport { appWindowUtil } from \"@shared/utils/renderer\";\n\nconst numberArray = Array(65)\n    .fill(0)\n    .map((_, index) => 16 + index);\n\nexport default function Lyric() {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--lyric-container\">\n            <IfTruthy condition={getGlobalContext().platform === \"darwin\"}>\n                <CheckBoxSettingItem\n                    label={t(\"settings.lyric.enable_status_bar_lyric\")}\n                    keyPath=\"lyric.enableStatusBarLyric\"\n                ></CheckBoxSettingItem>\n            </IfTruthy>\n            <CheckBoxSettingItem\n                label={t(\"settings.lyric.enable_desktop_lyric\")}\n                keyPath=\"lyric.enableDesktopLyric\"\n                onChange={(_evt, checked) => {\n                    appWindowUtil.setLyricWindow(checked);\n                }}\n            ></CheckBoxSettingItem>\n            {/* <CheckBoxSettingItem\n        label=\"置顶桌面歌词\"\n        checked={data.alwaysOnTop}\n        keyPath=\"lyric.alwaysOnTop\"\n      ></CheckBoxSettingItem> */}\n            <CheckBoxSettingItem\n                label={t(\"settings.lyric.lock_desktop_lyric\")}\n                keyPath=\"lyric.lockLyric\"\n            ></CheckBoxSettingItem>\n            <FontPickerSettingItem\n                label={t(\"settings.lyric.font\")}\n                keyPath=\"lyric.fontData\"\n            ></FontPickerSettingItem>\n            <ListBoxSettingItem\n                keyPath=\"lyric.fontSize\"\n                options={numberArray}\n                label={t(\"settings.lyric.font_size\")}\n            ></ListBoxSettingItem>\n            <ColorPickerSettingItem\n                label={t(\"settings.lyric.font_color\")}\n                keyPath=\"lyric.fontColor\"\n            ></ColorPickerSettingItem>\n            <ColorPickerSettingItem\n                label={t(\"settings.lyric.stroke_color\")}\n                keyPath=\"lyric.strokeColor\"\n            ></ColorPickerSettingItem>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Network/index.scss",
    "content": ".setting-view--network-container {\n  width: 100%;\n\n  & .proxy-container {\n    display: grid;\n    grid-template-columns: 33% 33%;\n    gap: 18px;\n\n    & .proxy-item {\n      display: flex;\n\n      & input {\n        flex: 1;\n      }\n    }\n  }\n\n  & .network-cache-container {\n    display: flex;\n    align-items: center;\n    gap: 24px;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Network/index.tsx",
    "content": "import \"./index.scss\";\nimport CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport InputSettingItem from \"../../components/InputSettingItem\";\nimport { useEffect, useState } from \"react\";\nimport { normalizeFileSize } from \"@/common/normalize-util\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { appUtil } from \"@shared/utils/renderer\";\n\n\nexport default function Network() {\n    const proxyEnabled = !!useAppConfig(\"network.proxy.enabled\");\n\n    const [cacheSize, setCacheSize] = useState(NaN);\n\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        appUtil.getCacheSize().then((res) => {\n            setCacheSize(res);\n        });\n    }, []);\n\n    return (\n        <div className=\"setting-view--network-container\">\n            <CheckBoxSettingItem\n                label={t(\"settings.network.enable_network_proxy\")}\n                keyPath=\"network.proxy.enabled\"\n            ></CheckBoxSettingItem>\n\n            <div className=\"proxy-container\">\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.network.host\")}\n                    disabled={!proxyEnabled}\n                    keyPath=\"network.proxy.host\"\n                    trim\n                ></InputSettingItem>\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.network.port\")}\n                    disabled={!proxyEnabled}\n                    keyPath=\"network.proxy.port\"\n                    trim\n                ></InputSettingItem>\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.network.username\")}\n                    disabled={!proxyEnabled}\n                    keyPath=\"network.proxy.username\"\n                    trim\n                ></InputSettingItem>\n                <InputSettingItem\n                    width=\"100%\"\n                    label={t(\"settings.network.password\")}\n                    type=\"password\"\n                    disabled={!proxyEnabled}\n                    keyPath=\"network.proxy.password\"\n                    trim\n                ></InputSettingItem>\n            </div>\n\n            <div className=\"setting-row network-cache-container\">\n                <Trans\n                    i18nKey={\"settings.network.local_cache\"}\n                    values={{\n                        cacheSize: isNaN(cacheSize) ? \"-\" : normalizeFileSize(cacheSize),\n                    }}\n                ></Trans>\n                <div\n                    role=\"button\"\n                    data-type=\"normalButton\"\n                    onClick={() => {\n                        setCacheSize(0);\n                        appUtil.clearCache();\n                    }}\n                >\n                    {t(\"settings.network.clear_cache\")}\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Normal/index.scss",
    "content": ".setting-view--normal-container {\n    width: 100%;\n}"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Normal/index.tsx",
    "content": "import RadioGroupSettingItem from \"../../components/RadioGroupSettingItem\";\nimport CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport MultiRadioGroupSettingItem from \"../../components/MultiRadioGroupSettingItem\";\nimport ListBoxSettingItem from \"../../components/ListBoxSettingItem\";\n\nimport \"./index.scss\";\nimport { changeLang, getLangList } from \"@/shared/i18n/renderer\";\nimport { toast } from \"react-toastify\";\nimport { useTranslation } from \"react-i18next\";\nimport { getGlobalContext } from \"@/shared/global-context/renderer\";\n\n\nexport default function Normal() {\n    const { t } = useTranslation();\n\n    const allLangs = getLangList();\n\n    return (\n        <div className=\"setting-view--normal-container\">\n            <CheckBoxSettingItem\n                label={t(\"settings.normal.check_update\")}\n                keyPath=\"normal.checkUpdate\"\n            ></CheckBoxSettingItem>\n            <CheckBoxSettingItem\n                label={t(\"settings.normal.auto_load_more\")}\n                keyPath=\"normal.autoLoadMore\"\n            ></CheckBoxSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.normal.close_behavior\")}\n                keyPath=\"normal.closeBehavior\"\n                options={[\n                    \"exit_app\",\n                    \"minimize\",\n                ]}\n                renderItem={(item) => t(\"settings.normal.\" + item)}\n            ></RadioGroupSettingItem>\n            {getGlobalContext().platform === \"win32\" ? (\n                <RadioGroupSettingItem\n                    label={t(\"settings.normal.taskbar_thumb\")}\n                    keyPath=\"normal.taskbarThumb\"\n                    options={[\n                        \"artwork\",\n                        \"window\",\n                    ]}\n                    renderItem={item => {\n                        if (item === \"artwork\") {\n                            return t(\"settings.normal.current_artwork\");\n                        } else {\n                            return t(\"settings.normal.main_window\");\n                        }\n                    }}\n\n                ></RadioGroupSettingItem>\n            ) : null}\n            <RadioGroupSettingItem\n                label={t(\"settings.normal.max_history_length\")}\n                keyPath=\"normal.maxHistoryLength\"\n                options={[15, 30, 50, 100, 200]}\n            ></RadioGroupSettingItem>\n            <MultiRadioGroupSettingItem\n                label={t(\"settings.normal.music_list_hide_columns\")}\n                keyPath=\"normal.musicListColumnsShown\"\n                options={[\n                    \"duration\",\n                    \"platform\",\n                ]}\n                renderItem={(item) => {\n                    return t(\"media.media_\" + item);\n                }}\n            ></MultiRadioGroupSettingItem>\n            <ListBoxSettingItem\n                label={t(\"settings.normal.languages\")}\n                keyPath=\"normal.language\"\n                width={\"240px\"}\n                onChange={async (evt, lang) => {\n                    evt.preventDefault();\n                    const success = await changeLang(lang);\n                    if (!success) {\n                        toast.warning(t(\"settings.normal.toast_switch_language_fail\"));\n                    }\n                }}\n                options={allLangs}\n            ></ListBoxSettingItem>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/PlayMusic/index.scss",
    "content": ".setting-view--play-music-container {\n  width: 100%;\n  \n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/PlayMusic/index.tsx",
    "content": "import \"./index.scss\";\nimport RadioGroupSettingItem from \"../../components/RadioGroupSettingItem\";\nimport CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport { useOutputAudioDevices } from \"@/hooks/useMediaDevices\";\nimport ListBoxSettingItem from \"../../components/ListBoxSettingItem\";\nimport trackPlayer from \"@renderer/core/track-player\";\nimport { useTranslation } from \"react-i18next\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\n\nexport default function PlayMusic() {\n    const audioDevices = useOutputAudioDevices();\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--play-music-container\">\n            <CheckBoxSettingItem\n                keyPath=\"playMusic.caseSensitiveInSearch\"\n                label={t(\"settings.play_music.case_sensitive_in_search\")}\n            ></CheckBoxSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.play_music.default_play_quality\")}\n                keyPath=\"playMusic.defaultQuality\"\n                options={[\n                    \"low\",\n                    \"standard\",\n                    \"high\",\n                    \"super\",\n                ]}\n                renderItem={it => t(\"media.music_quality_\" + it)}\n\n            ></RadioGroupSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.play_music.when_quality_missing\")}\n                keyPath=\"playMusic.whenQualityMissing\"\n                options={[\"lower\", \"higher\", \"skip\"]}\n                renderItem={it => t(\"settings.play_music.play_\" + it + \"_quality_version\")}\n            ></RadioGroupSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.play_music.when_play_error\")}\n                keyPath=\"playMusic.playError\"\n                options={[\"pause\", \"skip\"]}\n                renderItem={it => {\n                    if (it === \"pause\") {\n                        return t(\"settings.play_music.pause\");\n                    } else {\n                        return t(\"settings.play_music.skip_to_next\");\n                    }\n                }}\n            ></RadioGroupSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.play_music.double_click_music_list\")}\n                keyPath=\"playMusic.clickMusicList\"\n                options={[\"normal\", \"replace\"]}\n                renderItem={it => {\n                    if (it === \"normal\") {\n                        return t(\"settings.play_music.add_music_to_playlist\");\n                    } else {\n                        return t(\"settings.play_music.replace_playlist_with_musiclist\");\n                    }\n                }}\n\n            ></RadioGroupSettingItem>\n            <ListBoxSettingItem\n                label={t(\"settings.play_music.audio_output_device\")}\n                keyPath=\"playMusic.audioOutputDevice\"\n                renderItem={(item) => {\n                    return item ? item.label : t(\"common.default\");\n                }}\n                width={\"320px\"}\n                onChange={async (evt, item) => {\n                    evt.preventDefault();\n                    await trackPlayer.setAudioOutputDevice(item.deviceId);\n                    AppConfig.setConfig({\n                        \"playMusic.audioOutputDevice\": item.toJSON(),\n                    });\n                }}\n                options={audioDevices}\n            ></ListBoxSettingItem>\n            <RadioGroupSettingItem\n                label={t(\"settings.play_music.when_device_removed\")}\n                keyPath=\"playMusic.whenDeviceRemoved\"\n                renderItem={it => {\n                    if (it === \"pause\") {\n                        return t(\"settings.play_music.pause\");\n                    } else {\n                        return t(\"settings.play_music.continue_playing\");\n                    }\n                }}\n                options={[\"pause\", \"play\"]}\n            ></RadioGroupSettingItem>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Plugin/index.scss",
    "content": ".setting-view--plugin-container {\n  width: 100%;\n  \n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/Plugin/index.tsx",
    "content": "import \"./index.scss\";\nimport CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport { useTranslation } from \"react-i18next\";\n\n\n\nexport default function Plugin() {\n\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--plugin-container\">\n            <CheckBoxSettingItem\n                keyPath=\"plugin.autoUpdatePlugin\"\n                label={t(\"settings.plugin.auto_update_plugin\")}\n            ></CheckBoxSettingItem>\n            <CheckBoxSettingItem\n                label={t(\"settings.plugin.not_check_plugin_version\")}\n                keyPath=\"plugin.notCheckPluginVersion\"\n            ></CheckBoxSettingItem>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/ShortCut/index.scss",
    "content": ".setting-view--short-cut-container {\n  width: 100%;\n}\n\n.setting-view--short-cut-table-row {\n  width: 45rem;\n  display: flex;\n  align-items: center;\n  height: 3rem;\n  line-height: 3rem;\n  justify-content: space-between;\n\n  & .short-cut-cell {\n    width: 14rem;\n    flex-shrink: 0;\n    flex-grow: 0;\n    white-space: nowrap;\n    user-select: none;\n    ime-mode: disabled;\n\n    &:first-child {\n      width: 9rem;\n    }\n\n    & .short-cut-item--container {\n      position: relative;\n      display: flex;\n      align-items: center;\n\n      & [data-disabled=true] {\n        opacity: 0.6;\n        pointer-events: none;\n      }\n\n      &:hover {\n        & .short-cut-item--clear-button {\n          opacity: 1;\n        }\n      }\n\n      & .short-cut-item--clear-button {\n        position: absolute;\n        right: 4px;\n        width: 1.2rem;\n        height: 1.2rem;\n        line-height: 0;\n        transition: opacity ease-in-out 0.2s;\n        opacity: 0;\n\n        & svg {\n          height: 1.2rem;\n          width: 1.2rem;\n        }\n      }\n\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/ShortCut/index.tsx",
    "content": "import \"./index.scss\";\nimport CheckBoxSettingItem from \"../../components/CheckBoxSettingItem\";\nimport { useEffect, useRef, useState } from \"react\";\n\nimport hotkeys from \"hotkeys-js\";\nimport { useTranslation } from \"react-i18next\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport shortCut from \"@shared/short-cut/renderer\";\nimport { shortCutKeys } from \"@/common/constant\";\nimport SvgAsset from \"@renderer/components/SvgAsset\";\n\n\nexport default function ShortCut() {\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"setting-view--short-cut-container\">\n            <CheckBoxSettingItem\n                keyPath=\"shortCut.enableLocal\"\n                label={t(\"settings.short_cut.enable_local\")}\n            ></CheckBoxSettingItem>\n            <CheckBoxSettingItem\n                keyPath=\"shortCut.enableGlobal\"\n                label={t(\"settings.short_cut.enable_global\")}\n            ></CheckBoxSettingItem>\n            <ShortCutTable></ShortCutTable>\n        </div>\n    );\n}\n\ntype IShortCutKeys = keyof IAppConfig[\"shortCut.shortcuts\"];\n\n\nfunction ShortCutTable() {\n    const { t } = useTranslation();\n\n    const enableLocalShortCut = useAppConfig(\"shortCut.enableLocal\");\n    const enableGlobalShortCut = useAppConfig(\"shortCut.enableGlobal\");\n    const shortCuts = useAppConfig(\"shortCut.shortcuts\");\n\n\n    return (\n        <div className=\"setting-view--short-cut-table-container\">\n            <div className=\"setting-view--short-cut-table-row\">\n                <div className=\"short-cut-cell\">{t(\"settings.short_cut.ability\")}</div>\n                <div className=\"short-cut-cell\">\n                    {t(\"settings.short_cut.enable_local\")}\n                </div>\n                <div className=\"short-cut-cell\">\n                    {t(\"settings.short_cut.enable_global\")}\n                </div>\n            </div>\n            {shortCutKeys.map((it: string) => (\n                <div className=\"setting-view--short-cut-table-row\" key={it}>\n                    <div className=\"short-cut-cell\">{t(`settings.short_cut.${it}`)}</div>\n                    <div className=\"short-cut-cell\">\n                        <ShortCutItem\n                            enabled={enableLocalShortCut}\n                            value={shortCuts?.[it]?.local}\n                            onChange={(val) => {\n                                shortCut.registerLocalShortCut(it as IShortCutKeys, val);\n                            }}\n                            showClearButton\n                            onClear={() => {\n                                shortCut.unregisterLocalShortCut(it as IShortCutKeys);\n                            }}\n                        ></ShortCutItem>\n                    </div>\n                    <div className=\"short-cut-cell\">\n                        <ShortCutItem\n                            enabled={enableGlobalShortCut}\n                            value={shortCuts?.[it]?.global}\n                            onChange={(val) => {\n                                shortCut.registerGlobalShortCut(it as IShortCutKeys, val);\n                            }}\n                            showClearButton\n                            onClear={() => {\n                                shortCut.unregisterGlobalShortCut(it as IShortCutKeys);\n                            }}\n                        ></ShortCutItem>\n                    </div>\n                </div>\n            ))}\n        </div>\n    );\n}\n\ninterface IShortCutItemProps {\n    enabled?: boolean;\n    isGlobal?: boolean;\n    value?: string[];\n    onChange?: (sc?: string[]) => void;\n    showClearButton?: boolean;\n    onClear?: () => void;\n}\n\nfunction formatValue(val: string[]) {\n    return val.join(\" + \");\n}\n\nfunction keyCodeMap(code: string) {\n    switch (code) {\n        case \"arrowup\":\n            return \"Up\";\n        case \"arrowdown\":\n            return \"Down\";\n        case \"arrowleft\":\n            return \"Left\";\n        case \"arrowright\":\n            return \"Right\";\n        default:\n            return code;\n    }\n}\n\nfunction ShortCutItem(props: IShortCutItemProps) {\n    const { value, onChange, enabled, isGlobal, showClearButton, onClear } = props;\n    const [tmpValue, setTmpValue] = useState<string[] | null>();\n    const realValue = formatValue(tmpValue ?? value ?? []);\n    const isRecordingRef = useRef(false);\n    const scopeRef = useRef(Math.random().toString().slice(2));\n    const recordedKeysRef = useRef(new Set<string>());\n    const { t } = useTranslation();\n\n    useEffect(() => {\n        hotkeys(\n            \"*\",\n            {\n                scope: scopeRef.current,\n                keyup: true,\n            },\n            (evt) => {\n                const type = evt.type;\n                let key = evt.key.toLowerCase();\n                if (evt.code === \"Space\") {\n                    key = \"Space\";\n                }\n                if (type === \"keydown\") {\n                    isRecordingRef.current = true;\n                    if (key === \"meta\") {\n                        setTmpValue(null);\n                        isRecordingRef.current = false;\n                        recordedKeysRef.current.clear();\n                    } else {\n                        if (!recordedKeysRef.current.has(key)) {\n                            recordedKeysRef.current.add(key);\n                            setTmpValue(\n                                [...recordedKeysRef.current].map((it) =>\n                                    it.replace(/^(.)/, (_, $1: string) => $1.toUpperCase()),\n                                ),\n                            );\n                        }\n                    }\n                } else if (type === \"keyup\" && isRecordingRef.current) {\n                    isRecordingRef.current = false;\n                    // 开始结算\n                    const recordedSet = recordedKeysRef.current;\n                    const _recordShortCutKey = [];\n\n                    let statusCode = 0;\n                    if (recordedSet.has(\"ctrl\") || recordedSet.has(\"control\")) {\n                        _recordShortCutKey.push(\"Ctrl\");\n                        recordedSet.delete(\"ctrl\");\n                        recordedSet.delete(\"control\");\n                        statusCode |= 1;\n                    }\n                    if (recordedSet.has(\"command\")) {\n                        _recordShortCutKey.push(\"Command\");\n                        recordedSet.delete(\"command\");\n                        statusCode |= 1;\n                    }\n                    if (recordedSet.has(\"option\")) {\n                        _recordShortCutKey.push(\"Option\");\n                        recordedSet.delete(\"option\");\n                        statusCode |= 1;\n                    }\n                    if (recordedSet.has(\"shift\")) {\n                        _recordShortCutKey.push(\"Shift\");\n                        recordedSet.delete(\"shift\");\n                        statusCode |= 1;\n                    }\n\n                    if (recordedSet.has(\"alt\")) {\n                        _recordShortCutKey.push(\"Alt\");\n                        recordedSet.delete(\"alt\");\n                        statusCode |= 1;\n                    }\n\n                    if (recordedSet.size === 1 && (isGlobal ? statusCode : true)) {\n                        _recordShortCutKey.push(\n                            keyCodeMap([...recordedSet.values()][0]).replace(\n                                /^(.)/,\n                                (_, $1: string) => $1.toUpperCase(),\n                            ),\n                        );\n                        setTmpValue(_recordShortCutKey);\n                        onChange?.(_recordShortCutKey);\n                    } else {\n                        setTmpValue(null);\n                    }\n\n                    recordedKeysRef.current.clear();\n                }\n            },\n        );\n    }, []);\n\n    return (\n        <div className=\"short-cut-item--container\">\n            <input\n                data-capture=\"true\"\n                data-disabled={!enabled}\n                data-show-clear-button={showClearButton}\n                autoCorrect=\"off\"\n                autoCapitalize=\"off\"\n                type=\"text\"\n                readOnly\n                aria-live=\"off\"\n                className=\"short-cut-item--input\"\n                value={realValue || t(\"settings.short_cut.no_short_cut\")}\n                onKeyDown={(e) => {\n                    e.preventDefault();\n                }}\n                onFocus={() => {\n                    hotkeys.setScope(scopeRef.current);\n                }}\n                onBlur={() => {\n                    hotkeys.setScope(\"all\");\n                    setTmpValue(null);\n                    recordedKeysRef.current.clear();\n                }}\n            >\n            </input>\n            {\n                (enabled && showClearButton) ? <div className='short-cut-item--clear-button' role=\"button\" onClick={onClear}>\n                    <SvgAsset iconName='x-mark'></SvgAsset>\n                </div> : null\n            }\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/setting-view/routers/index.ts",
    "content": "/** 配置 */\nimport About from \"./About\";\nimport Backup from \"./Backup\";\nimport Download from \"./Download\";\nimport Lyric from \"./Lyric\";\nimport Network from \"./Network\";\nimport Normal from \"./Normal\";\nimport PlayMusic from \"./PlayMusic\";\nimport Plugin from \"./Plugin\";\nimport ShortCut from \"./ShortCut\";\n\nexport default [\n    {\n        id: \"normal\",\n        component: Normal,\n    },\n    {\n        id: \"playMusic\",\n        component: PlayMusic,\n    },\n    {\n        id: \"download\",\n        component: Download,\n    },\n    {\n        id: \"lyric\",\n        component: Lyric,\n    },\n    {\n        id: \"plugin\",\n        component: Plugin,\n    },\n    {\n        id: \"shortCut\",\n        component: ShortCut,\n    },\n    {\n        id: \"network\",\n        component: Network,\n    },\n    {\n        id: \"backup\",\n        component: Backup,\n    },\n    {\n        id: \"about\",\n        component: About,\n    },\n];\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/LocalThemes/index.scss",
    "content": ".local-themes-container {\n  width: 100%;\n  margin-top: 14px;\n\n  & .local-themes-inner-container {\n    display: grid;\n    gap: 24px 36px;\n    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n  }\n}\n\n.theme-install-local {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: 1px dashed var(--dividerColor);\n  cursor: pointer;\n\n  & svg {\n    height: 60%;\n    opacity: 0.6;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/LocalThemes/index.tsx",
    "content": "import { toast } from \"react-toastify\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { useTranslation } from \"react-i18next\";\nimport ThemePack from \"@/shared/themepack/renderer\";\nimport ThemeItem from \"../ThemeItem\";\n\nimport \"./index.scss\";\nimport { dialogUtil } from \"@shared/utils/renderer\";\n\nexport default function LocalThemes() {\n    const currentThemePack = ThemePack.useCurrentThemePack();\n    const localThemePacks = ThemePack.useLocalThemePacks();\n\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"local-themes-container\">\n            <div className=\"local-themes-inner-container\">\n                <div className=\"theme-item-container\">\n                    <div\n                        title={t(\"theme.install_theme\")}\n                        className=\"theme-thumb-container theme-install-local\"\n                        onClick={async () => {\n                            try {\n                                const result = await dialogUtil.showOpenDialog({\n                                    title: t(\"theme.install_theme\"),\n                                    buttonLabel: t(\"common.install\"),\n                                    filters: [\n                                        {\n                                            name: t(\"theme.musicfree_theme\"),\n                                            extensions: [\"mftheme\", \"zip\"],\n                                        },\n                                        {\n                                            name: t(\"theme.all_files\"),\n                                            extensions: [\"*\"],\n                                        },\n                                    ],\n                                    properties: [\"openFile\", \"multiSelections\"],\n                                });\n\n                                if (!result.canceled) {\n                                    const themePackPaths = result.filePaths;\n                                    for (const themePackPath of themePackPaths) {\n                                        const themePackConfig = await ThemePack.installThemePack(\n                                            themePackPath,\n                                        );\n                                        toast.success(\n                                            t(\"theme.install_theme_success\", {\n                                                name: themePackConfig?.name\n                                                    ? `「${themePackConfig.name}」`\n                                                    : \"\",\n                                            }),\n                                        );\n                                    }\n                                }\n                            } catch (e) {\n                                toast.warn(\n                                    t(\"theme.install_theme_fail\", {\n                                        name: e?.message ? `「${e.message}」` : \"\",\n                                    }),\n                                );\n                            }\n                        }}\n                    >\n                        <SvgAsset iconName=\"plus\"></SvgAsset>\n                    </div>\n                </div>\n\n                {localThemePacks.map((it) => (\n                    <ThemeItem\n                        config={it}\n                        hash={it.hash}\n                        key={it.path}\n                        type=\"local\"\n                        selected={it.hash === currentThemePack?.hash}\n                    ></ThemeItem>\n                ))}\n                <ThemeItem\n                    config={\n                        {\n                            name: t(\"common.default\"),\n                            preview: \"#f17d34\",\n                        } as any\n                    }\n                    type=\"local\"\n                    selected={!currentThemePack}\n                ></ThemeItem>\n            </div>\n        </div>\n    );\n}\n\n// function ThemeItem(props: IThemeItemProps) {\n//   const { selected, themePack } = props;\n\n//   const { t } = useTranslation();\n\n//   return (\n//     <div\n//       className=\"theme-item-container\"\n//       role=\"button\"\n//       onClick={() => {\n//         Themepack.selectTheme(themePack);\n//       }}\n//       onContextMenu={(e) => {\n//         if (!themePack) {\n//           return;\n//         }\n//         showThemeContextMenu(themePack, e.clientX, e.clientY);\n//       }}\n//       title={themePack?.description}\n//     >\n//       <div\n//         className={classNames({\n//           \"theme-item-preview\": true,\n//           \"theme-item-preview-selected\": selected,\n//         })}\n//         style={{\n//           background:\n//             themePack === null\n//               ? \"#f17d34\"\n//               : themePack.preview.startsWith(\"#\")\n//               ? themePack.preview\n//               : `center/cover no-repeat url(${themePack.preview})`,\n//         }}\n//       ></div>\n//       <div className=\"theme-item-title\">\n//         {themePack ? themePack.name : t(\"common.default\")}\n//       </div>\n//     </div>\n//   );\n// }\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/hooks/useRemoteThemes.ts",
    "content": "import { RequestStateCode, themePackStoreBaseUrl } from \"@/common/constant\";\nimport useMounted from \"@/hooks/useMounted\";\nimport Themepack from \"@/shared/themepack/renderer\";\nimport axios from \"axios\";\nimport { useEffect, useState } from \"react\";\n\nlet themeStoreConfig: IThemeStoreItem[];\n\ninterface IThemeStoreItem {\n    publishName: string;\n    hash: string;\n    packageName: string;\n    config: ICommon.IThemePack;\n    id?: string;\n}\n\nfunction raceWithData<T>(promises: Array<Promise<T>>): Promise<T> {\n    const promiseCount = promises.length;\n    return new Promise((resolve, reject) => {\n        let isResolved = false;\n        let rejectedNum = 0;\n        promises.forEach((promise) => {\n            promise\n                .then((data) => {\n                    if (!isResolved) {\n                        isResolved = true;\n                        resolve(data);\n                    }\n                })\n                .catch((e) => {\n                    ++rejectedNum;\n                    if (rejectedNum === promiseCount) {\n                        reject(e);\n                    }\n                });\n        });\n    });\n}\n\nexport default function () {\n    const [themes, setThemes] = useState(themeStoreConfig || []);\n    const [loadingState, setLoadingState] = useState(\n        RequestStateCode.PENDING_FIRST_PAGE,\n    );\n    const isMounted = useMounted();\n\n    useEffect(() => {\n        if (themeStoreConfig) {\n            setThemes(themeStoreConfig);\n            setLoadingState(RequestStateCode.FINISHED);\n        } else {\n            raceWithData(\n                themePackStoreBaseUrl.map(\n                    async (it, index) =>\n                        [await axios.get(it + \".publish/publish.json\").then(res => {\n                            if (typeof res.data !== \"object\") {\n                                throw new Error(\"Invalid data\");\n                            }\n                            return res;\n                        }), index] as const,\n                ),\n            )\n                .then(([res, index]) => {\n                    const data: IThemeStoreItem[] = res.data;\n                    const pickedUrl = themePackStoreBaseUrl[index];\n\n                    data.forEach((theme) => {\n                        theme.config.srcUrl = `${pickedUrl}.publish/${theme.publishName}.mftheme`;\n                        if (theme.config.preview) {\n                            theme.config.preview = Themepack.replaceAlias(\n                                theme.config.preview,\n                                pickedUrl + theme.packageName + \"/\",\n                                false,\n                            );\n                        }\n                        if (theme.config.thumb) {\n                            theme.config.thumb = Themepack.replaceAlias(\n                                theme.config.thumb,\n                                pickedUrl + theme.packageName + \"/\",\n                                false,\n                            );\n                        }\n                    });\n                    themeStoreConfig = data;\n\n                    if (isMounted.current) {\n                        setLoadingState(RequestStateCode.FINISHED);\n                        setThemes(data);\n                    }\n                })\n                .catch((e) => {\n                    setLoadingState(RequestStateCode.ERROR);\n                });\n        }\n    }, []);\n\n    return [themes, loadingState] as const;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/index.scss",
    "content": ".remote-themes-container {\n  width: 100%;\n  margin-top: 14px;\n\n  & .remote-themes-inner-container {\n    display: grid;\n    gap: 24px 36px;\n    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n  }\n\n  & .remote-themes-description {\n    margin: 1em 0;\n  }\n\n  & .remote-themes-load-error {\n    width: 100%;\n    height: 200px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/RemoteThemes/index.tsx",
    "content": "import Loading from \"@/renderer/components/Loading\";\nimport \"./index.scss\";\nimport useRemoteThemes from \"./hooks/useRemoteThemes\";\nimport SwitchCase from \"@/renderer/components/SwitchCase\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport ThemeItem from \"../ThemeItem\";\nimport ThemePack from \"@/shared/themepack/renderer\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport A from \"@/renderer/components/A\";\n\nexport default function RemoteThemes() {\n    const [themes, loadingState] = useRemoteThemes();\n    const currentTheme = ThemePack.useCurrentThemePack();\n    const localThemes = ThemePack.useLocalThemePacks();\n    const { t } = useTranslation();\n\n    return (\n        <div className=\"remote-themes-container\">\n            <div className=\"remote-themes-description\">\n                <Trans\n                    i18nKey={\"theme.how_to_submit_new_theme\"}\n                    components={{\n                        Github: (\n                            <A href=\"https://github.com/maotoumao/MusicFreeThemePacks\"></A>\n                        ),\n                    }}\n                ></Trans>\n            </div>\n            <SwitchCase.Switch switch={loadingState}>\n                <SwitchCase.Case case={RequestStateCode.PENDING_FIRST_PAGE}>\n                    <Loading></Loading>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={RequestStateCode.FINISHED}>\n                    <div className=\"remote-themes-inner-container\">\n                        {themes.map((it) => (\n                            <ThemeItem\n                                config={it.config}\n                                hash={it.hash}\n                                key={it.publishName}\n                                type=\"remote\"\n                                selected={it.hash && it.hash === currentTheme?.hash}\n                                latestInstalled={\n                                    it.hash &&\n                  localThemes.some((localTheme) => it.hash === localTheme.hash)\n                                }\n                                installed={\n                                    it.id &&\n                  localThemes.some((localTheme) => it.id === localTheme.id)\n                                }\n                            ></ThemeItem>\n                        ))}\n                    </div>\n                </SwitchCase.Case>\n                <SwitchCase.Case case={RequestStateCode.ERROR}>\n                    <div className=\"remote-themes-load-error\">\n                        {t(\"theme.load_remote_theme_error\")}\n                    </div>\n                </SwitchCase.Case>\n            </SwitchCase.Switch>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/ThemeItem/index.scss",
    "content": ".theme-item-container {\n  width: 100%;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  gap: 0.5em;\n\n  & .theme-thumb-container {\n    height: 100px;\n    position: relative;\n    overflow: hidden;\n    border-radius: 6px;\n\n    & .theme-selected {\n      width: 100%;\n      height: 100%;\n      box-sizing: border-box;\n      position: absolute;\n      top: 0;\n      left: 0;\n      bottom: 0;\n      right: 0;\n      border-radius: 6px;\n      border: 4px solid var(--primaryColor);\n    }\n\n    & .theme-thumb {\n      position: absolute;\n      top: 0;\n      left: 0;\n      bottom: 0;\n      right: 0;\n      width: 100%;\n      height: 100%;\n      object-fit: cover;\n    }\n\n    & .theme-options-mask {\n      position: absolute;\n      top: 0;\n      left: 0;\n      bottom: 0;\n      right: 0;\n      width: 100%;\n      height: 100%;\n      transition: all 300ms ease-in-out;\n      display: flex;\n      flex-direction: column;\n      justify-content: center;\n      align-items: center;\n      gap: 8px;\n      opacity: 0;\n\n      &[data-show=\"true\"] {\n        opacity: 1;\n        background-color: rgba($color: #000, $alpha: 0.8);\n      }\n\n      & .theme-downloading {\n        transform: scale(0.7);\n      }\n\n      & .theme-option-button {\n        border-radius: 4px;\n        border: 1px solid currentColor;\n        font-size: 0.9rem;\n        padding: 4px 10px;\n        width: fit-content;\n        opacity: 0.8;\n        user-select: none;\n        color: white;\n\n        &:hover {\n          opacity: 1;\n        }\n      }\n    }\n  }\n\n  & .theme-name {\n    font-size: 1rem;\n    font-weight: 600;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    user-select: none;\n    cursor: pointer;\n\n    &:hover {\n      color: var(--primaryColor);\n    }\n  }\n\n  & .theme-author {\n    font-size: 0.9rem;\n    opacity: 0.7;\n    user-select: none;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/components/ThemeItem/index.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport \"./index.scss\";\nimport { If, IfTruthy } from \"@/renderer/components/Condition\";\nimport { useState } from \"react\";\nimport Themepack from \"@/shared/themepack/renderer\";\nimport { toast } from \"react-toastify\";\nimport Loading from \"@/renderer/components/Loading\";\n\ninterface IProps {\n    config: ICommon.IThemePack;\n    hash?: string;\n    type: \"remote\" | \"local\";\n    selected?: boolean;\n    /**[Remote Only] 主题的最新版是否已经在本地安装 */\n    latestInstalled?: boolean;\n    /**[Remote Only] 主题是否已经在本地安装 */\n    installed?: boolean;\n}\n\nexport default function ThemeItem(props: IProps) {\n    const { config, type, selected, latestInstalled, installed, hash } = props;\n\n    const [isHover, setIsHover] = useState(false);\n    const [isLoading, setIsLoading] = useState(false);\n\n    const { t } = useTranslation();\n\n    const selectTheme = async () => {\n        try {\n            if (type === \"local\") {\n                await Themepack.selectTheme(config);\n            } else {\n                if (latestInstalled) {\n                    await Themepack.selectThemeByHash(hash);\n                } else {\n                    setIsLoading(true);\n                    const themePack = await Themepack.installRemoteThemePack(\n                        config.srcUrl,\n                        config.id,\n                    );\n                    await Themepack.selectTheme(themePack);\n                }\n            }\n        } catch (e) {\n            toast.error(\n                t(\"theme.invalid_theme\", {\n                    reason: e?.message ?? \"\",\n                }),\n            );\n        }\n        setIsLoading(false);\n    };\n\n    return (\n        <div\n            className=\"theme-item-container\"\n            onMouseEnter={() => {\n                setIsHover(true);\n            }}\n            onMouseLeave={() => {\n                setIsHover(false);\n            }}\n        >\n            <div className=\"theme-thumb-container\">\n                {config.preview?.startsWith(\"#\") ? (\n                    <div\n                        className=\"theme-thumb\"\n                        style={{\n                            backgroundColor: config.preview,\n                        }}\n                    ></div>\n                ) : (\n                    <img src={config.preview} className=\"theme-thumb\"></img>\n                )}\n                <IfTruthy condition={selected}>\n                    <div className=\"theme-selected\"></div>\n                </IfTruthy>\n                <div className=\"theme-options-mask\" data-show={isHover || isLoading}>\n                    {isLoading ? (\n                        <div className=\"theme-downloading\">\n                            <Loading text={t(\"common.downloading\")}></Loading>\n                        </div>\n                    ) : (\n                        <If condition={type === \"remote\"}>\n                            <If.Truthy>\n                                <div\n                                    className=\"theme-option-button\"\n                                    role=\"button\"\n                                    onClick={selectTheme}\n                                >\n                                    {latestInstalled\n                                        ? t(\"theme.use_theme\")\n                                        : installed\n                                            ? t(\"theme.update_theme\")\n                                            : t(\"theme.download_and_use\")}\n                                </div>\n                            </If.Truthy>\n                            <If.Falsy>\n                                <div\n                                    className=\"theme-option-button\"\n                                    role=\"button\"\n                                    onClick={selectTheme}\n                                >\n                                    {t(\"theme.use_theme\")}\n                                </div>\n                                {hash && (\n                                    <div\n                                        className=\"theme-option-button\"\n                                        role=\"button\"\n                                        onClick={() => {\n                                            Themepack.uninstallThemePack(config);\n                                        }}\n                                    >\n                                        {t(\"common.uninstall\")}\n                                    </div>\n                                )}\n                            </If.Falsy>\n                        </If>\n                    )}\n                </div>\n            </div>\n\n            <div\n                className=\"theme-name\"\n                title={config.description || config.name}\n                onClick={selectTheme}\n            >\n                {config.name}\n            </div>\n            <IfTruthy condition={config.author}>\n                <div className=\"theme-author\">\n                    {t(\"media.media_type_artist\")}: {config.author}\n                </div>\n            </IfTruthy>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/index.scss",
    "content": ""
  },
  {
    "path": "src/renderer/pages/main-page/views/theme-view/index.tsx",
    "content": "import { Tab } from \"@headlessui/react\";\nimport React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport RemoteThemes from \"./components/RemoteThemes\";\nimport LocalThemes from \"./components/LocalThemes\";\n\nconst routes = [\"local\", \"remote\"];\n\nexport default function ThemeView() {\n    const { t } = useTranslation();\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            <Tab.Group>\n                <Tab.List className=\"tab-list-container\">\n                    {routes.map((it) => (\n                        <Tab key={it} as=\"div\" className=\"tab-list-item\">\n                            {t(`theme.tab_${it}`)}\n                        </Tab>\n                    ))}\n                </Tab.List>\n                <Tab.Panels className={\"tab-panels-container\"}>\n                    <Tab.Panel>\n                        <LocalThemes></LocalThemes>\n                    </Tab.Panel>\n                    <Tab.Panel>\n                        <RemoteThemes></RemoteThemes>\n                    </Tab.Panel>\n                </Tab.Panels>\n            </Tab.Group>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-detail-view/hooks/useTopListDetail.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport { useEffect, useRef, useState } from \"react\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function useTopListDetail(\n    topListItem: IMusic.IMusicSheetItem | null,\n    platform: string,\n) {\n    const [mergedTopListItem, setMergedTopListItem] =\n    useState<ICommon.WithMusicList<IMusic.IMusicSheetItem> | null>(topListItem);\n    const pageRef = useRef(1);\n    const [requestState, setRequestState] = useState(RequestStateCode.IDLE);\n\n    async function loadMore(){\n        if (!topListItem) {\n            return;\n        }\n        try {\n            if (pageRef.current === 1) {\n                setRequestState(RequestStateCode.PENDING_FIRST_PAGE);\n            } else {\n                setRequestState(RequestStateCode.PENDING_REST_PAGE);\n            }\n            const result = await PluginManager.callPluginDelegateMethod({ platform }, \"getTopListDetail\", topListItem, pageRef.current);\n            if (!result) {\n                throw new Error();\n            }\n            const currentPage = pageRef.current;\n            setMergedTopListItem((prev) => ({\n                ...prev,\n                ...(result.topListItem),\n                musicList: currentPage === 1 ? (result.musicList ?? []): [...prev.musicList, ...result.musicList],\n            }));\n\n            if (!result.isEnd) {\n                setRequestState(RequestStateCode.PARTLY_DONE);\n            } else {\n                setRequestState(RequestStateCode.FINISHED);\n            }\n            pageRef.current++;\n        } catch {\n            setRequestState(RequestStateCode.FINISHED);\n        }\n    }\n\n    useEffect(() => {\n        if (topListItem === null) {\n            return;\n        }\n        loadMore();\n    }, []);\n    return [mergedTopListItem, requestState, loadMore] as const;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-detail-view/index.tsx",
    "content": "import useTopListDetail from \"./hooks/useTopListDetail\";\nimport { useParams } from \"react-router-dom\";\nimport MusicSheetlikeView from \"@/renderer/components/MusicSheetlikeView\";\n\nexport default function TopListDetailView() {\n    const params = useParams();\n    const [topListDetail, state, loadMore] = useTopListDetail(\n        history.state?.usr?.toplist,\n        params?.platform,\n    );\n\n    return (\n        <div id=\"page-container\" className=\"page-container\">\n            <MusicSheetlikeView\n                musicSheet={topListDetail}\n                musicList={topListDetail?.musicList ?? []}\n                state={state}\n                onLoadMore={loadMore}\n            />\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-view/hooks/useGetTopList.ts",
    "content": "import { produce } from \"immer\";\nimport { useCallback } from \"react\";\nimport { pluginsTopListStore } from \"../store\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport { useStore } from \"@/common/store\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function useGetTopList() {\n    const [pluginsTopList, setPluginsTopList] = useStore(pluginsTopListStore);\n\n    const getTopList = useCallback(\n        async (pluginHash: string) => {\n            try {\n                // 有数据/加载中直接返回\n                if (\n                    pluginsTopList[pluginHash]?.data?.length ||\n          pluginsTopList[pluginHash]?.state &\n            RequestStateCode.PENDING_FIRST_PAGE\n                ) {\n                    return;\n                }\n\n                setPluginsTopList(\n                    produce((draft) => {\n                        draft[pluginHash] = {\n                            state: RequestStateCode.PENDING_FIRST_PAGE,\n                            data: [],\n                        };\n                    }),\n                );\n                const result = await PluginManager.callPluginDelegateMethod(\n                    { hash: pluginHash },\n                    \"getTopLists\",\n                );\n                setPluginsTopList(\n                    produce((draft) => {\n                        draft[pluginHash] = {\n                            data: result,\n                            state: RequestStateCode.FINISHED,\n                        };\n                    }),\n                );\n            } catch {\n                setPluginsTopList(\n                    produce((draft) => {\n                        draft[pluginHash].state = RequestStateCode.FINISHED;\n                    }),\n                );\n            }\n        },\n        [pluginsTopList],\n    );\n\n    return getTopList;\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-view/index.scss",
    "content": ".toplist-view--container {\n  height: 100%;\n  \n  & .toplist-group-item--container {\n    & .header {\n      font-weight: 600;\n      font-size: 1.5rem;\n      margin-top: 1.5rem;\n      letter-spacing: 0.05rem;\n      user-select: none;\n    }\n\n    & .body {\n      margin-top: 1.5rem;\n      display: grid;\n      width: 100%;\n      grid-template-columns: repeat(5, 1fr);\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-view/index.tsx",
    "content": "import Condition from \"@/renderer/components/Condition\";\nimport MusicSheetlikeItem from \"@/renderer/components/MusicSheetlikeItem\";\nimport { Tab } from \"@headlessui/react\";\nimport { pluginsTopListStore } from \"./store\";\nimport { RequestStateCode } from \"@/common/constant\";\nimport Loading from \"@/renderer/components/Loading\";\nimport { useNavigate } from \"react-router-dom\";\nimport { useEffect } from \"react\";\nimport useGetTopList from \"./hooks/useGetTopList\";\nimport NoPlugin from \"@/renderer/components/NoPlugin\";\nimport Empty from \"@/renderer/components/Empty\";\nimport { useTranslation } from \"react-i18next\";\n\nimport \"./index.scss\";\nimport PluginManager from \"@shared/plugin-manager/renderer\";\n\nexport default function ToplistView() {\n    const availablePlugins = PluginManager.getSortedSupportedPlugin(\"getTopLists\");\n    const navigate = useNavigate();\n    const { t } = useTranslation();\n\n    return (\n        <div id=\"page-container\" className=\"page-container toplist-view--container\">\n            <Condition\n                condition={availablePlugins.length}\n                falsy={\n                    <NoPlugin\n                        supportMethod={t(\"plugin.method_get_top_lists\")}\n                        height={\"100%\"}\n                    ></NoPlugin>\n                }\n            >\n                <Tab.Group\n                    defaultIndex={history.state?.usr?.pluginIndex}\n                    onChange={(index) => {\n                        const usr = history.state.usr ?? {};\n\n                        navigate(\"\", {\n                            replace: true,\n                            state: {\n                                ...usr,\n                                pluginIndex: index,\n                            },\n                        });\n                    }}\n                >\n                    <Tab.List className=\"tab-list-container\">\n                        {availablePlugins.map((plugin) => (\n                            <Tab key={plugin.hash} as=\"div\" className=\"tab-list-item\">\n                                {plugin.platform}\n                            </Tab>\n                        ))}\n                    </Tab.List>\n                    <Tab.Panels className={\"tab-panels-container\"}>\n                        {availablePlugins.map((plugin) => (\n                            <Tab.Panel className=\"tab-panel-container\" key={plugin.hash}>\n                                <ToplistBody plugin={plugin}></ToplistBody>\n                            </Tab.Panel>\n                        ))}\n                    </Tab.Panels>\n                </Tab.Group>\n            </Condition>\n        </div>\n    );\n}\n\ninterface IToplistBodyProps {\n    plugin: IPlugin.IPluginDelegate;\n}\n\nfunction ToplistBody(props: IToplistBodyProps) {\n    const topLists = pluginsTopListStore.useValue();\n    const { plugin } = props;\n    const getTopList = useGetTopList();\n\n    useEffect(() => {\n        getTopList(plugin.hash);\n    }, []);\n\n    return (\n        <Condition\n            condition={\n                topLists[plugin.hash]?.state !== RequestStateCode.PENDING_FIRST_PAGE\n            }\n            falsy={<Loading></Loading>}\n        >\n            <Condition\n                condition={topLists[plugin.hash]?.data?.length}\n                falsy={<Empty></Empty>}\n            >\n                {topLists[plugin.hash]?.data?.map((item, index) => (\n                    <ToplistGroupItem\n                        groupItem={item}\n                        key={index}\n                        platform={plugin.platform}\n                    ></ToplistGroupItem>\n                ))}\n            </Condition>\n        </Condition>\n    );\n}\n\ninterface IToplistGroupItemProps {\n    groupItem: IMusic.IMusicSheetGroupItem;\n    platform: string;\n}\nfunction ToplistGroupItem(props: IToplistGroupItemProps) {\n    const { groupItem, platform } = props;\n    const navigate = useNavigate();\n\n    return (\n        <div className=\"toplist-group-item--container\">\n            <Condition condition={groupItem.title}>\n                <div className=\"header\">{groupItem.title}</div>\n            </Condition>\n            <div className=\"body\">\n                {(groupItem.data ?? []).map((item) => (\n                    <MusicSheetlikeItem\n                        key={item.id}\n                        mediaItem={item}\n                        onClick={(mediaItem) => {\n                            navigate(`/main/toplist-detail/${platform}`, {\n                                state: {\n                                    toplist: {\n                                        ...mediaItem,\n                                        platform,\n                                    },\n                                },\n                            });\n                        }}\n                    ></MusicSheetlikeItem>\n                ))}\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer/pages/main-page/views/toplist-view/store/index.ts",
    "content": "import { RequestStateCode } from \"@/common/constant\";\nimport Store from \"@/common/store\";\n\nexport interface IPluginTopListResult {\n    state: RequestStateCode;\n    data: IMusic.IMusicSheetGroupItem[];\n}\n\nexport const pluginsTopListStore = new Store<Record<string, IPluginTopListResult>>({});\n\n"
  },
  {
    "path": "src/renderer/utils/check-update.ts",
    "content": "import { compare } from \"compare-versions\";\nimport { showModal } from \"../components/Modal\";\nimport { getUserPreference } from \"./user-perference\";\nimport { appUtil } from \"@shared/utils/renderer\";\n\nexport default async function checkUpdate(forceCheck?: boolean) {\n    /** checkupdate */\n    const updateInfo = await appUtil.checkUpdate();\n    if (updateInfo.update) {\n        const skipVersion = getUserPreference(\"skipVersion\");\n        if (\n            !forceCheck &&\n      skipVersion &&\n      compare(updateInfo.version, skipVersion, \"<=\")\n        ) {\n            return false;\n        }\n        showModal(\"Update\", {\n            currentVersion: updateInfo.version,\n            update: updateInfo.update,\n        });\n        return true;\n    }\n    return false;\n}\n"
  },
  {
    "path": "src/renderer/utils/classnames.ts",
    "content": "export default function classNames(cls: Record<string, boolean> | Array<string>) {\n    if(Array.isArray(cls)){\n        return cls.join(\" \");\n    }\n    return Object.getOwnPropertyNames(cls).filter(cl => cls[cl]).join(\" \");\n}"
  },
  {
    "path": "src/renderer/utils/create-tmp-file.ts",
    "content": "import { getGlobalContext } from \"@/shared/global-context/renderer\";\nimport { nanoid } from \"nanoid\";\nimport { fsUtil } from \"@shared/utils/renderer\";\n\nexport async function createTmpFile(data: string) {\n    const { appPath } = getGlobalContext();\n    if (!appPath.temp) {\n        throw new Error(\"TempFile Path NotFound\");\n    }\n    const randomFileName = nanoid();\n    const filePath = window.path.resolve(appPath.temp, randomFileName);\n    await fsUtil.writeFile(filePath, data, \"utf-8\");\n\n    return {\n        fileName: randomFileName,\n        filePath,\n        async clearTmpFile() {\n            await fsUtil.rimraf(filePath);\n        },\n    };\n}\n"
  },
  {
    "path": "src/renderer/utils/get-text-width.ts",
    "content": "\nlet canvas: HTMLCanvasElement;\n\ninterface IConfig {\n    fontSize?: string | number;\n    fontFamily?: string;\n}\n\nexport default function(text: string, config: IConfig){\n    let { fontSize = \"1rem\", fontFamily = \"sans-serif\" } = config;\n\n    if(typeof fontSize === \"number\") {\n        fontSize = `${fontSize}px`;\n    }\n    if(!canvas) {\n        canvas = document.createElement(\"canvas\");\n    }\n    const ctx = canvas.getContext(\"2d\");\n    ctx.font = `${fontSize} ${fontFamily ?? \"\"}`;\n    const metrics = ctx.measureText(text);\n\n    return metrics.width;\n}"
  },
  {
    "path": "src/renderer/utils/get-url-ext.ts",
    "content": "export default function getUrlExt(url?: string) {\n    if (!url) {\n        return;\n    }\n    const urlObj = new URL(url);\n    const ext = window.path.extname(urlObj.pathname);\n    return ext;\n}\n"
  },
  {
    "path": "src/renderer/utils/groupBy.ts",
    "content": "type IndexableType = number | string | symbol;\n\nexport default function groupBy<T extends Record<IndexableType, any>, K extends keyof T> (values: T[], keyFinder: K | ((item: T) => K) )  {\n    // if(Object.groupBy) {\n    //     return Object.groupBy(values, keyFinder);\n    // }\n    // using reduce to aggregate values\n    return values.reduce((a, b) => {\n        // depending upon the type of keyFinder\n        // if it is function, pass the value to it\n        // if it is a property, access the property\n        const key: K = typeof keyFinder === \"function\" ? keyFinder(b) : b[keyFinder];\n      \n        // aggregate values based on the keys\n        if(!a[key]){\n            a[key] = [b];\n        }else{\n            a[key] = [...a[key], b];\n        }\n      \n        return a;\n    }, {} as Record<K, T[]>);\n}"
  },
  {
    "path": "src/renderer/utils/img-on-error.ts",
    "content": "import albumImg from \"@/assets/imgs/album-cover.jpg\";\nimport { SyntheticEvent } from \"react\";\n\nexport function setFallbackAlbum(evt: SyntheticEvent<HTMLImageElement>) {\n    (evt.target as HTMLImageElement).src = albumImg;\n}\n"
  },
  {
    "path": "src/renderer/utils/is-local-music.ts",
    "content": "import { localPluginName } from \"@/common/constant\";\n\nexport default function isLocalMusic(mediaItem: IMedia.IMediaBase) {\n    return mediaItem?.platform === localPluginName;\n}\n"
  },
  {
    "path": "src/renderer/utils/lyric-parser.ts",
    "content": "const timeReg = /\\[[\\d:.]+\\]/g;\nconst metaReg = /\\[(.+):(.+)\\]/g;\n\ntype LyricMeta = Record<string, any>;\n\ninterface IOptions {\n    musicItem?: IMusic.IMusicItem;\n    translation?: string;\n}\n\nexport interface IParsedLrcItem {\n    /** 时间 s */\n    time: number;\n    /** 歌词 */\n    lrc: string;\n    /** 翻译 */\n    translation?: string;\n    /** 位置 */\n    index: number;\n}\n\nexport default class LyricParser {\n    private _musicItem?: IMusic.IMusicItem;\n\n    private meta: LyricMeta;\n    private lrcItems: Array<IParsedLrcItem>;\n\n    private lastSearchIndex = 0;\n\n    public hasTranslation = false;\n\n    get musicItem() {\n        return this._musicItem;\n    }\n\n    constructor(raw: string, options?: IOptions) {\n    // init\n        this._musicItem = options?.musicItem;\n        let translation = options?.translation;\n        if (!raw && translation) {\n            raw = translation;\n            translation = undefined;\n        }\n\n        const { lrcItems, meta } = this.parseLyricImpl(raw);\n        this.meta = meta;\n        this.lrcItems = lrcItems;\n\n        if (translation) {\n            this.hasTranslation = true;\n            const transLrcItems = this.parseLyricImpl(translation).lrcItems;\n\n            // 2 pointer\n            let p1 = 0;\n            let p2 = 0;\n\n            while (p1 < this.lrcItems.length) {\n                const lrcItem = this.lrcItems[p1];\n                while (\n                    transLrcItems[p2].time < lrcItem.time &&\n          p2 < transLrcItems.length - 1\n                ) {\n                    ++p2;\n                }\n                if (transLrcItems[p2].time === lrcItem.time) {\n                    lrcItem.translation = transLrcItems[p2].lrc;\n                } else {\n                    lrcItem.translation = \"\";\n                }\n\n                ++p1;\n            }\n        }\n    }\n\n    getPosition(position: number): IParsedLrcItem | null {\n        position = position - (this.meta?.offset ?? 0);\n        let index;\n        /** 最前面 */\n        if (!this.lrcItems[0] || position < this.lrcItems[0].time) {\n            this.lastSearchIndex = 0;\n            return null;\n        }\n        for (\n            index = this.lastSearchIndex;\n            index < this.lrcItems.length - 1;\n            ++index\n        ) {\n            if (\n                position >= this.lrcItems[index].time &&\n        position < this.lrcItems[index + 1].time\n            ) {\n                this.lastSearchIndex = index;\n                return this.lrcItems[index];\n            }\n        }\n\n        for (index = 0; index < this.lastSearchIndex; ++index) {\n            if (\n                position >= this.lrcItems[index].time &&\n        position < this.lrcItems[index + 1].time\n            ) {\n                this.lastSearchIndex = index;\n                return this.lrcItems[index];\n            }\n        }\n\n        index = this.lrcItems.length - 1;\n        this.lastSearchIndex = index;\n        return this.lrcItems[index];\n    }\n\n    getLyricItems() {\n        return this.lrcItems;\n    }\n\n    getMeta() {\n        return this.meta;\n    }\n\n    toString(options?: {\n        withTimestamp?: boolean;\n        type?: \"raw\" | \"translation\";\n    }) {\n        const { type = \"raw\", withTimestamp = true } = options || {};\n\n        if (withTimestamp) {\n            return this.lrcItems\n                .map(\n                    (item) =>\n                        `${this.timeToLrctime(item.time)} ${\n                            type === \"raw\" ? item.lrc : item.translation\n                        }`,\n                )\n                .join(\"\\r\\n\");\n        } else {\n            return this.lrcItems\n                .map((item) => (type === \"raw\" ? item.lrc : item.translation))\n                .join(\"\\r\\n\");\n        }\n    }\n\n    /** [xx:xx.xx] => x s */\n    private parseTime(timeStr: string): number {\n        let result = 0;\n        const nums = timeStr.slice(1, timeStr.length - 1).split(\":\");\n        for (let i = 0; i < nums.length; ++i) {\n            result = result * 60 + +nums[i];\n        }\n        return result;\n    }\n    /** x s => [xx:xx.xx] */\n    private timeToLrctime(sec: number) {\n        const min = Math.floor(sec / 60);\n        sec = sec - min * 60;\n        const secInt = Math.floor(sec);\n        const secFloat = sec - secInt;\n        return `[${min.toFixed(0).padStart(2, \"0\")}:${secInt\n            .toString()\n            .padStart(2, \"0\")}.${secFloat.toFixed(2).slice(2)}]`;\n    }\n\n    private parseMetaImpl(metaStr: string) {\n        if (metaStr === \"\") {\n            return {};\n        }\n        const metaArr = metaStr.match(metaReg) ?? [];\n        const meta: any = {};\n        let k, v;\n        for (const m of metaArr) {\n            k = m.substring(1, m.indexOf(\":\"));\n            v = m.substring(k.length + 2, m.length - 1);\n            if (k === \"offset\") {\n                meta[k] = +v / 1000;\n            } else {\n                meta[k] = v;\n            }\n        }\n        return meta;\n    }\n\n    private parseLyricImpl(raw: string) {\n        raw = raw.trim();\n        const rawLrcItems: Array<IParsedLrcItem> = [];\n        const rawLrcs = raw.split(timeReg) ?? [];\n        const rawTimes = raw.match(timeReg) ?? [];\n        const len = rawTimes.length;\n\n        const meta = this.parseMetaImpl(rawLrcs[0].trim());\n        rawLrcs.shift();\n\n        let counter = 0;\n        let j, lrc;\n        for (let i = 0; i < len; ++i) {\n            counter = 0;\n            while (rawLrcs[0] === \"\") {\n                ++counter;\n                rawLrcs.shift();\n            }\n            lrc = rawLrcs[0]?.trim?.() ?? \"\";\n            for (j = i; j < i + counter; ++j) {\n                rawLrcItems.push({\n                    time: this.parseTime(rawTimes[j]),\n                    lrc,\n                    index: j,\n                });\n            }\n            i += counter;\n            if (i < len) {\n                rawLrcItems.push({\n                    time: this.parseTime(rawTimes[i]),\n                    lrc,\n                    index: j,\n                });\n            }\n            rawLrcs.shift();\n        }\n        let lrcItems = rawLrcItems.sort((a, b) => a.time - b.time);\n        if (lrcItems.length === 0 && raw.length) {\n            lrcItems = raw.split(\"\\n\").map((_, index) => ({\n                time: 0,\n                lrc: _,\n                index,\n            }));\n        }\n\n        return {\n            lrcItems,\n            meta,\n        };\n    }\n}\n"
  },
  {
    "path": "src/renderer/utils/preload-util.ts",
    "content": ""
  },
  {
    "path": "src/renderer/utils/raf2.ts",
    "content": "export default function (fn: (...args: any) => void) {\n    requestAnimationFrame(() => {\n        requestAnimationFrame(fn);\n    });\n}\n"
  },
  {
    "path": "src/renderer/utils/search-history.ts",
    "content": "import { getUserPreferenceIDB, setUserPreferenceIDB } from \"./user-perference\";\nimport AppConfig from \"@shared/app-config/renderer\";\n\nexport async function getSearchHistory() {\n    return (await getUserPreferenceIDB(\"searchHistory\")) ?? [];\n}\n\nexport async function addSearchHistory(searchItem: string) {\n    const oldSearchHistory = await getSearchHistory();\n    const maxHistoryLen = AppConfig.getConfig(\"normal.maxHistoryLength\");\n    const newSearchHistory = [\n        searchItem,\n        ...oldSearchHistory.filter((item) => item !== searchItem),\n    ].slice(0, maxHistoryLen);\n    await setUserPreferenceIDB(\"searchHistory\", newSearchHistory);\n}\n\nexport async function removeSearchHistory(searchItem: string) {\n    const oldSearchHistory = await getSearchHistory();\n    const newSearchHistory = oldSearchHistory.filter(\n        (item) => item !== searchItem,\n    );\n    await setUserPreferenceIDB(\"searchHistory\", newSearchHistory);\n}\n\nexport async function clearSearchHistory() {\n    await setUserPreferenceIDB(\"searchHistory\", []);\n}\n"
  },
  {
    "path": "src/renderer/utils/user-perference.ts",
    "content": "import { safeParse } from \"@/common/safe-serialization\";\nimport Dexie, { Table } from \"dexie\";\nimport EventEmitter from \"eventemitter3\";\nimport { useEffect, useState } from \"react\";\n\nconst basicType = [\"number\", \"string\", \"boolean\", \"null\", \"undefined\"];\n\nconst ee = new EventEmitter();\n\nenum EvtNames {\n    USER_PREFERENCE_UPDATE = \"USER_PREFERENCE_UPDATE\",\n}\n\nexport function setUserPreference<K extends keyof IUserPreference.IType>(\n    key: K,\n    value: IUserPreference.IType[K],\n) {\n    try {\n        let newValue;\n        if (typeof value in basicType) {\n            newValue = value as any;\n        } else {\n            newValue = JSON.stringify(value);\n        }\n        localStorage.setItem(key, newValue as any);\n        ee.emit(EvtNames.USER_PREFERENCE_UPDATE, key, value);\n    } catch {\n    // 设置失败\n    }\n}\n\nexport function removeUserPreference(key: keyof IUserPreference.IType) {\n    try {\n        localStorage.removeItem(key);\n        ee.emit(EvtNames.USER_PREFERENCE_UPDATE, key, null);\n    } catch {}\n}\n\nexport function getUserPreference<K extends keyof IUserPreference.IType>(\n    key: K,\n): IUserPreference.IType[K] | null {\n    let rawData = null;\n    try {\n        rawData = localStorage.getItem(key);\n        if (!rawData) {\n            return null;\n        }\n        return JSON.parse(rawData);\n    } catch {\n        return rawData as any;\n    }\n}\n\nexport function useUserPreference<K extends keyof IUserPreference.IType>(\n    key: K,\n) {\n    const [state, _setState] = useState(getUserPreference(key));\n\n    function setState(newState: IUserPreference.IType[K] | null) {\n        setUserPreference(key, newState);\n    }\n\n    useEffect(() => {\n        const updateFn = (updateKey: K, value: IUserPreference.IType[K] | null) => {\n            if (key === updateKey) {\n                _setState(value);\n            }\n        };\n\n        const updateFnStorage = (e: StorageEvent) => {\n            if (e.key === key) {\n                try {\n                    _setState(JSON.parse(e.newValue));\n                } catch {\n                    _setState(e.newValue as any);\n                }\n            }\n        };\n\n        ee.on(EvtNames.USER_PREFERENCE_UPDATE, updateFn);\n        window.addEventListener(\"storage\", updateFnStorage);\n\n        return () => {\n            ee.off(EvtNames.USER_PREFERENCE_UPDATE, updateFn);\n            window.removeEventListener(\"storage\", updateFnStorage);\n        };\n    }, []);\n\n    return [state, setState] as const;\n}\n\n/** 比较大的数据 */\n\nclass UserPreferenceDB extends Dexie {\n    // 歌单信息，其中musiclist只存有platform和id\n    perference: Table<Record<string, any>>;\n\n    constructor() {\n        super(\"userPerferenceDB\");\n        this.version(1.0).stores({\n            perference: \"&key\",\n        });\n    }\n}\n\nconst upDB = new UserPreferenceDB();\n\nconst dbKeyUpdateCbs = new Map<\n    keyof IUserPreference.IDBType,\n    Set<(...args: any) => void>\n>();\n\nexport async function setUserPreferenceIDB<\n    K extends keyof IUserPreference.IDBType,\n>(key: K, value: IUserPreference.IDBType[K]) {\n    try {\n        await upDB.transaction(\"readwrite\", upDB.perference, async () => {\n            await upDB.perference.put({\n                key,\n                value,\n            });\n        });\n        const cb = dbKeyUpdateCbs.get(key);\n        cb?.forEach((it) => it?.(value));\n        return true;\n    } catch {\n        return false;\n    }\n}\n\nexport async function getUserPreferenceIDB<\n    K extends keyof IUserPreference.IDBType,\n>(key: K): Promise<IUserPreference.IDBType[K] | null> {\n    try {\n        return (\n            (\n                await upDB.transaction(\"readonly\", upDB.perference, async () => {\n                    return await upDB.perference.get(key);\n                })\n            )?.value ?? null\n        );\n    } catch {\n        return null;\n    }\n}\n\nexport function useUserPreferenceIDBValue<\n    K extends keyof IUserPreference.IDBType,\n>(key: K) {\n    const [state, setState] = useState<IUserPreference.IDBType[K] | null>(null);\n\n    useEffect(() => {\n        (async () => {\n            try {\n                const result = await getUserPreferenceIDB(key);\n                setState(result);\n            } catch {\n            } finally {\n                if (dbKeyUpdateCbs.has(key)) {\n                    dbKeyUpdateCbs.get(key).add(setState);\n                } else {\n                    dbKeyUpdateCbs.set(key, new Set([setState]));\n                }\n            }\n        })();\n    }, []);\n\n    return state;\n}\n"
  },
  {
    "path": "src/renderer-lrc/document/bootstrap.ts",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\nimport messageBus from \"@shared/message-bus/renderer/extension\";\n\nexport default async function () {\n    // let prevTimestamp = 0;\n    await AppConfig.setup();\n    messageBus.subscribeAppState([\"playerState\", \"musicItem\", \"repeatMode\", \"parsedLrc\", \"lyricText\", \"fullLyric\", \"progress\"]);\n    messageBus.sendCommand(\"SyncAppState\");\n}\n"
  },
  {
    "path": "src/renderer-lrc/document/index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Music Free</title>\n    <meta name=\"referrer\" content=\"no-referrer\"/>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "src/renderer-lrc/document/index.tsx",
    "content": "import ReactDOM from \"react-dom/client\";\nimport bootstrap from \"./bootstrap\";\nimport LyricWindowPage from \"../pages\";\nimport { useEffect } from \"react\";\n\nimport \"animate.css\";\nimport \"rc-slider/assets/index.css\";\nimport \"react-toastify/dist/ReactToastify.css\";\nimport \"./styles/index.scss\"; // 全局样式\nimport WindowDrag from \"@shared/window-drag/renderer\";\n\nbootstrap().then(() => {\n    ReactDOM.createRoot(document.getElementById(\"root\")).render(<Root></Root>);\n});\n\nfunction Root() {\n    useEffect(() => {\n        WindowDrag.injectHandler();\n    }, []);\n\n    return <LyricWindowPage></LyricWindowPage>;\n}\n"
  },
  {
    "path": "src/renderer-lrc/document/styles/index.scss",
    "content": "html,\nbody,\n#root {\n    margin: 0;\n    width: 100vw;\n    height: 100vh;\n    overflow: hidden;\n}\n\n::-webkit-scrollbar {\n    width: var(--scrollbarWidth, 8px);\n    height: 8px;\n}\n\n::-webkit-scrollbar-thumb {\n    background-color: #b2b2b2;\n    border-radius: 8px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background-color: #a0a0a0;\n}\n\n::-webkit-scrollbar-thumb:active {\n    background-color: #a0a0a0;\n}\n\n::-webkit-scrollbar-track {\n    background-color: rgba(0, 0, 0, 0.1);\n}\n\n.blur10 {\n    backdrop-filter: blur(10px);\n}\n\n.opacity-button {\n    opacity: 0.6;\n\n    &:hover {\n        opacity: 1;\n    }\n}\n\n.highlight {\n    color: var(--primaryColor) !important;\n}\n\ninput {\n    outline: none;\n    font-size: 1rem;\n    padding: 0.4rem 0.6rem;\n    border-radius: 2px;\n    border: 1px solid var(--dividerColor);\n    box-sizing: border-box;\n\n    &::placeholder {\n        opacity: 0.6;\n    }\n}\n\ndiv[role=\"button\"] {\n    cursor: pointer;\n    user-select: none;\n\n    &[data-disabled]:not([data-disabled=\"false\"]) {\n        cursor: default;\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    &[data-type=\"primaryButton\"] {\n        background-color: var(--primaryColor);\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: white;\n        width: fit-content;\n        line-height: 1em;\n    }\n\n    &[data-type=\"normalButton\"] {\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: var(--textColor);\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n    }\n\n    &[data-type=\"dangerButton\"] {\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: #fc5f5f;\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n\n        &[data-fill=\"true\"] {\n            color: white;\n            background: #fc5f5f;\n        }\n    }\n}\n\n.divider {\n    width: 100%;\n    height: 1px;\n    background-color: var(--dividerColor);\n    margin-top: 12px;\n    margin-bottom: 12px;\n}"
  },
  {
    "path": "src/renderer-lrc/pages/index.scss",
    "content": ".container {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  cursor: default;\n  user-select: none;\n\n  &:not(.lock-lyric):hover {\n    background-color: rgba($color: #000000, $alpha: 0.2);\n  }\n\n  &::after {\n    display: block;\n    content: '';\n    height: 14px;\n  }\n\n  & .operation-outer-container {\n    width: 100%;\n    height: 46px;\n    display: flex;\n    align-items: flex-end;\n    justify-content: center;\n  }\n\n\n  & .content-container {\n    width: 100%;\n    height: calc(100% - 60px);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n\n    & .lyric-text-row {\n      -webkit-text-stroke: 1px #b48f1d;\n      color: white;\n      font-size: 48px;\n      white-space: nowrap;\n      position: absolute;\n    }\n  }\n\n\n  & .operation-container {\n    height: 28px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    column-gap: 16px;\n\n    & .operation-button {\n      width: 28px;\n      height: 28px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      color: white;\n      cursor: pointer;\n      filter: drop-shadow(0px 0px 2px rgba($color: #000000, $alpha: 0.6));\n\n      & svg {\n        width: 24px;\n        height: 24px;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer-lrc/pages/index.tsx",
    "content": "import \"./index.scss\";\nimport classNames from \"@/renderer/utils/classnames\";\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport Condition from \"@/renderer/components/Condition\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { PlayerState } from \"@/common/constant\";\nimport getTextWidth from \"@/renderer/utils/get-text-width\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { appWindowUtil } from \"@shared/utils/renderer\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport messageBus, { useAppStatePartial } from \"@shared/message-bus/renderer/extension\";\nimport { IAppState } from \"@shared/message-bus/type\";\n\nexport default function LyricWindowPage() {\n    const currentMusic = useAppStatePartial(\"musicItem\");\n    const playerState = useAppStatePartial(\"playerState\");\n    const lockLyric = useAppConfig(\"lyric.lockLyric\");\n    const [showOperations, setShowOperations] = useState(false);\n\n    const mouseOverTimerRef = useRef<number | null>(null);\n\n    useEffect(() => {\n        if (lockLyric) {\n            setShowOperations(false);\n        }\n    }, [lockLyric]);\n\n    return (\n        <div\n            className={classNames({\n                \"container\": true,\n                \"lock-lyric\": lockLyric,\n            })}\n            onMouseOver={() => {\n                if (!lockLyric || mouseOverTimerRef.current) {\n                    if (!lockLyric) {\n                        setShowOperations(true);\n                    }\n                    return;\n                }\n                mouseOverTimerRef.current = window.setTimeout(() => {\n                    setShowOperations(true);\n                    clearTimeout(mouseOverTimerRef.current);\n                    mouseOverTimerRef.current = null;\n                }, 1000);\n            }}\n            onMouseLeave={() => {\n                setShowOperations(false);\n                if (mouseOverTimerRef.current) {\n                    clearTimeout(mouseOverTimerRef.current);\n                    mouseOverTimerRef.current = null;\n                }\n            }}\n        >\n            <div className='operation-outer-container'>\n                <Condition condition={showOperations}>\n                    <div className=\"operation-container\">\n                        <Condition\n                            condition={!lockLyric}\n                            falsy={\n                                <div\n                                    className=\"operation-button\"\n                                    onClick={() => {\n                                        AppConfig.setConfig({\n                                            \"lyric.lockLyric\": false,\n                                        });\n                                    }}\n                                    onMouseOver={() => {\n                                        appWindowUtil.ignoreMouseEvent(false);\n                                    }}\n                                    onMouseLeave={() => {\n                                        appWindowUtil.ignoreMouseEvent(true);\n                                    }}\n                                >\n                                    <SvgAsset iconName=\"lock-open\"></SvgAsset>\n                                </div>\n                            }\n                        >\n                            <div\n                                className=\"operation-button\"\n                                onClick={() => {\n                                    messageBus.sendCommand(\"SkipToPrevious\");\n                                }}\n                            >\n                                <SvgAsset iconName=\"skip-left\"></SvgAsset>\n                            </div>\n                            <div\n                                className=\"operation-button\"\n                                onClick={() => {\n                                    if (currentMusic) {\n                                        messageBus.sendCommand(\"TogglePlayerState\");\n                                    }\n                                }}\n                            >\n                                <SvgAsset\n                                    iconName={\n                                        playerState === PlayerState.Playing ? \"pause\" : \"play\"\n                                    }\n                                ></SvgAsset>\n                            </div>\n                            <div\n                                className=\"operation-button\"\n                                onClick={() => {\n                                    messageBus.sendCommand(\"SkipToNext\");\n                                }}\n                            >\n                                <SvgAsset iconName=\"skip-right\"></SvgAsset>\n                            </div>\n                            <div\n                                className=\"operation-button\"\n                                onClick={() => {\n                                    AppConfig.setConfig({\n                                        \"lyric.lockLyric\": true,\n                                    });\n                                }}\n                            >\n                                <SvgAsset iconName=\"lock-closed\"></SvgAsset>\n                            </div>\n                            <div\n                                className=\"operation-button\"\n                                onClick={() => {\n                                    appWindowUtil.setLyricWindow(false);\n                                }}\n                            >\n                                <SvgAsset iconName=\"x-mark\"></SvgAsset>\n                            </div>\n                        </Condition>\n                    </div>\n                </Condition>\n            </div>\n            <div className=\"content-container\">\n                <LyricContent></LyricContent>\n            </div>\n        </div>\n    );\n}\n\nfunction LyricContent() {\n    const currentMusic = useAppStatePartial(\"musicItem\");\n    const currentLyric = useAppStatePartial(\"parsedLrc\");\n    const currentFullLyric = useAppStatePartial(\"fullLyric\");\n\n    const fontDataConfig = useAppConfig(\"lyric.fontData\");\n    const fontSizeConfig = useAppConfig(\"lyric.fontSize\");\n    const fontColorConfig = useAppConfig(\"lyric.fontColor\");\n    const fontStrokeConfig = useAppConfig(\"lyric.strokeColor\");\n\n    const [enableTransition, setEnableTransition] = useState(false);\n\n    const textWidth = useMemo(() => {\n        if (currentLyric?.lrc) {\n            return getTextWidth(currentLyric?.lrc, {\n                fontSize: fontSizeConfig ?? 48,\n                fontFamily: fontDataConfig?.family || undefined,\n            });\n        } else if (currentMusic) {\n            return getTextWidth(`${currentMusic.title} - ${currentMusic.artist}`, {\n                fontSize: fontSizeConfig ?? 48,\n                fontFamily: fontDataConfig?.family || undefined,\n            });\n        }\n        return 0;\n    }, [currentLyric, fontDataConfig, fontSizeConfig, currentMusic]);\n\n    const [left, setLeft] = useState(null);\n\n    useLayoutEffect(() => {\n        if (textWidth > window.innerWidth) {\n            setEnableTransition(false);\n            setLeft(0);\n        } else {\n            setLeft(null);\n        }\n    }, [textWidth]);\n\n    useLayoutEffect(() => {\n        const callback = (_: any, patch: IAppState) => {\n            if (!patch.progress) {\n                return;\n            }\n            if (textWidth > window.innerWidth) {\n                if (currentLyric && currentLyric.index > -1 && currentFullLyric) {\n                    const nextLyric = currentFullLyric[currentLyric.index + 1];\n                    if (nextLyric && (nextLyric.time > currentLyric.time)) {\n                        const diff = nextLyric.time - currentLyric.time;\n                        const virtualPointer = (patch.progress - currentLyric.time) / diff * textWidth;\n                        if (virtualPointer > window.innerWidth * 0.5) {\n                            setEnableTransition(true);\n                            setLeft(-Math.min((virtualPointer - window.innerWidth * 0.5) * 1.1, textWidth - window.innerWidth));\n                            return;\n                        }\n                    }\n                }\n                setEnableTransition(false);\n                setLeft(0);\n            } else {\n                setEnableTransition(false);\n                setLeft(null);\n            }\n        };\n        messageBus.onStateChange(callback);\n\n        return () => {\n            messageBus.offStateChange(callback);\n        };\n\n    }, [textWidth, currentFullLyric, currentLyric]);\n\n\n    return (\n        <div\n            className=\"lyric-text-row\"\n            style={{\n                color: fontColorConfig,\n                WebkitTextStrokeColor: fontStrokeConfig,\n                fontSize: fontSizeConfig,\n                fontFamily: fontDataConfig?.family || undefined,\n                left: left,\n                transition: enableTransition ? \"left 900ms linear\" : \"none\",\n            }}\n        >\n            {currentLyric?.lrc ??\n                (currentMusic\n                    ? `${currentMusic.title} - ${currentMusic.artist}`\n                    : \"暂无歌词\")}\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/renderer-minimode/document/bootstrap.ts",
    "content": "import { setupI18n } from \"@/shared/i18n/renderer\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport messageBus from \"@shared/message-bus/renderer/extension\";\n\nexport default async function () {\n    // TODO: broadcast\n    await AppConfig.setup();\n    await setupI18n();\n    messageBus.subscribeAppState([\"playerState\", \"musicItem\", \"repeatMode\", \"parsedLrc\", \"lyricText\"]);\n    messageBus.sendCommand(\"SyncAppState\");\n}\n"
  },
  {
    "path": "src/renderer-minimode/document/index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>Music Free</title>\n    <meta name=\"referrer\" content=\"no-referrer\"/>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "src/renderer-minimode/document/index.tsx",
    "content": "import ReactDOM from \"react-dom/client\";\n\nimport bootstrap from \"./bootstrap\";\n\nimport MinimodePage from \"../pages\";\nimport { useEffect } from \"react\";\n\nimport \"animate.css\";\nimport \"rc-slider/assets/index.css\";\nimport \"react-toastify/dist/ReactToastify.css\";\nimport \"./styles/index.scss\"; // 全局样式\nimport WindowDrag from \"@shared/window-drag/renderer\";\n\nbootstrap().then(() => {\n    ReactDOM.createRoot(document.getElementById(\"root\")).render(<Root></Root>);\n});\n\nfunction Root() {\n\n    useEffect(() => {\n        WindowDrag.injectHandler();\n    }, []);\n\n    return <MinimodePage></MinimodePage>;\n}\n"
  },
  {
    "path": "src/renderer-minimode/document/styles/index.scss",
    "content": "html,\nbody,\n#root {\n    margin: 0;\n    width: 100vw;\n    height: 100vh;\n    overflow: hidden;\n}\n\n::-webkit-scrollbar {\n    width: var(--scrollbarWidth, 8px);\n    height: 8px;\n}\n\n::-webkit-scrollbar-thumb {\n    background-color: #b2b2b2;\n    border-radius: 8px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background-color: #a0a0a0;\n}\n\n::-webkit-scrollbar-thumb:active {\n    background-color: #a0a0a0;\n}\n\n::-webkit-scrollbar-track {\n    background-color: rgba(0, 0, 0, 0.1);\n}\n\n.blur10 {\n    backdrop-filter: blur(10px);\n}\n\n.opacity-button {\n    opacity: 0.6;\n\n    &:hover {\n        opacity: 1;\n    }\n}\n\n.highlight {\n    color: var(--primaryColor) !important;\n}\n\ninput {\n    outline: none;\n    font-size: 1rem;\n    padding: 0.4rem 0.6rem;\n    border-radius: 2px;\n    border: 1px solid var(--dividerColor);\n    box-sizing: border-box;\n\n    &::placeholder {\n        opacity: 0.6;\n    }\n}\n\ndiv[role=\"button\"] {\n    cursor: pointer;\n    user-select: none;\n\n    &[data-disabled]:not([data-disabled=\"false\"]) {\n        cursor: default;\n        opacity: 0.5;\n        pointer-events: none;\n    }\n\n    &[data-type=\"primaryButton\"] {\n        background-color: var(--primaryColor);\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: white;\n        width: fit-content;\n        line-height: 1em;\n    }\n\n    &[data-type=\"normalButton\"] {\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: var(--textColor);\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n    }\n\n    &[data-type=\"dangerButton\"] {\n        font-size: 1em;\n        padding: 0.4em 1em;\n        border-radius: 1.6em;\n        color: #fc5f5f;\n        border: 1px solid currentColor;\n        width: fit-content;\n        line-height: 1em;\n\n        &[data-fill=\"true\"] {\n            color: white;\n            background: #fc5f5f;\n        }\n    }\n}\n\n.divider {\n    width: 100%;\n    height: 1px;\n    background-color: var(--dividerColor);\n    margin-top: 12px;\n    margin-bottom: 12px;\n}"
  },
  {
    "path": "src/renderer-minimode/pages/index.scss",
    "content": ".minimode-page-container {\n  width: 100%;\n  height: 100%;\n  background-color: transparent;\n  user-select: none;\n\n  & .minimode-header-container {\n    width: 100%;\n    height: 72px;\n    box-sizing: border-box;\n    padding: 8px;\n    display: flex;\n    gap: 12px;\n    align-items: center;\n    border-radius: 6px;\n\n    & .mini-mode-header-background-mask {\n      position: absolute;\n      top: 0;\n      left: 0;\n      right: 0;\n      bottom: 0;\n      z-index: -2;\n      background-color: #333333;\n    }\n\n    & .mini-mode-header-background {\n      position: absolute;\n      top: 0;\n      left: 0;\n      right: 0;\n      bottom: 0;\n      background-size: cover;\n      background-position: center;\n      z-index: -1;\n      opacity: 0.6;\n      transition: all 300ms ease-in-out;\n      filter: blur(15px);\n    }\n\n    & .album-container {\n      width: 56px;\n      height: 56px;\n      border-radius: 6px;\n      object-fit: cover;\n    }\n\n    & .body-container {\n      flex: 1;\n      height: 100%;\n      font-size: 13px;\n\n      & .text-container {\n        width: 100%;\n        height: 100%;\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        overflow: hidden;\n        color: white;\n\n        & span {\n          // 最多两行\n          display: -webkit-box;\n          -webkit-box-orient: vertical;\n          -webkit-line-clamp: 2;\n          overflow: hidden;\n          text-overflow: ellipsis;\n        }\n      }\n\n      & .options-container {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        gap: 20px;\n        height: 100%;\n        padding-right: 48px;\n        position: relative;\n\n        & .close-button {\n          position: absolute;\n          right: 0;\n          top: 0;\n          color: white;\n\n          & svg {\n            width: 16px;\n            height: 16px;\n          }\n        }\n\n        & .option-item {\n          color: white;\n\n          & svg {\n            width: 28px;\n            height: 28px;\n          }\n          opacity: 0.8;\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "src/renderer-minimode/pages/index.tsx",
    "content": "import { useState } from \"react\";\nimport SvgAsset from \"@/renderer/components/SvgAsset\";\nimport { PlayerState } from \"@/common/constant\";\nimport albumImg from \"@/assets/imgs/album-cover.jpg\";\n\nimport \"./index.scss\";\nimport { useTranslation } from \"react-i18next\";\nimport { useUserPreference } from \"@/renderer/utils/user-perference\";\nimport { appWindowUtil } from \"@shared/utils/renderer\";\nimport messageBus, { useAppStatePartial } from \"@shared/message-bus/renderer/extension\";\n\n\nexport default function MinimodePage() {\n    const [hover, setHover] = useState(false);\n    const currentMusicItem = useAppStatePartial(\"musicItem\");\n    const playerState = useAppStatePartial(\"playerState\");\n    const lyricItem = useAppStatePartial(\"parsedLrc\");\n\n    const { t } = useTranslation();\n    const [showTranslation] = useUserPreference(\"showTranslation\");\n\n    const textContent = (\n        <div className=\"text-container\">\n            <span>\n                {lyricItem?.lrc || currentMusicItem?.title || t(\"media.unknown_title\")}\n            </span>\n            {showTranslation ? <span>{lyricItem?.translation}</span> : null}\n        </div>\n    );\n\n    const options = (\n        <div className=\"options-container\">\n            <div\n                role=\"button\"\n                className=\"close-button\"\n                onClick={() => {\n                    appWindowUtil.setMinimodeWindow(false);\n                    appWindowUtil.showMainWindow();\n                }}\n            >\n                <SvgAsset iconName=\"x-mark\"></SvgAsset>\n            </div>\n            <div\n                role=\"button\"\n                className=\"option-item\"\n                onClick={() => {\n                    messageBus.sendCommand(\"SkipToPrevious\");\n                }}\n            >\n                <SvgAsset iconName=\"skip-left\"></SvgAsset>\n            </div>\n            <div\n                role=\"button\"\n                className=\"option-item\"\n                onClick={() => {\n                    messageBus.sendCommand(\n                        \"TogglePlayerState\",\n                    );\n                }}\n            >\n                <SvgAsset\n                    iconName={playerState === PlayerState.Playing ? \"pause\" : \"play\"}\n                ></SvgAsset>\n            </div>\n\n            <div\n                role=\"button\"\n                className=\"option-item\"\n                onClick={() => {\n                    messageBus.sendCommand(\"SkipToNext\");\n                }}\n            >\n                <SvgAsset iconName=\"skip-right\"></SvgAsset>\n            </div>\n        </div>\n    );\n\n    return (\n        <div className=\"minimode-page-container\">\n            <div\n                className=\"minimode-header-container\"\n                onMouseEnter={() => {\n                    setHover(true);\n                }}\n                onMouseLeave={() => {\n                    setHover(false);\n                }}\n            >\n                <div className=\"mini-mode-header-background-mask\"></div>\n                <div\n                    className=\"mini-mode-header-background\"\n                    style={{\n                        backgroundImage: `url(${currentMusicItem?.artwork || albumImg})`,\n                    }}\n                ></div>\n                <img\n                    title={\n                        (currentMusicItem?.title || t(\"media.unknown_title\")) +\n                        \" - \" +\n                        (currentMusicItem?.artist || t(\"media.unknown_artist\"))\n                    }\n                    draggable=\"false\"\n                    className=\"album-container\"\n                    src={currentMusicItem?.artwork || albumImg}\n                    onDoubleClick={() => {\n                        appWindowUtil.showMainWindow();\n                    }}\n                ></img>\n                <div className=\"body-container\">{hover ? options : textContent}</div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "src/shared/app-config/default-app-config.ts",
    "content": "import { defaultFont } from \"@/common/constant\";\nimport { IAppConfig } from \"@/types/app-config\";\n\nconst _defaultAppConfig: IAppConfig =  {\n    \"$schema-version\": 1,\n    \"playMusic.whenQualityMissing\": \"lower\",\n    \"playMusic.defaultQuality\": \"standard\",\n    \"playMusic.clickMusicList\": \"replace\",\n    \"playMusic.caseSensitiveInSearch\": false,\n    \"playMusic.playError\": \"skip\",\n    \"playMusic.whenDeviceRemoved\": \"play\",\n    \"normal.taskbarThumb\": \"window\",\n    \"normal.closeBehavior\": \"minimize\",\n    \"normal.checkUpdate\": true,\n    \"normal.maxHistoryLength\": 30,\n    \"download.defaultQuality\": \"standard\",\n    \"download.whenQualityMissing\": \"lower\",\n    \"lyric.enableDesktopLyric\": false,\n    \"lyric.alwaysOnTop\": false,\n    \"lyric.lockLyric\": false,\n    \"lyric.fontData\": defaultFont,\n    \"lyric.fontColor\": \"#fff\",\n    \"lyric.strokeColor\": \"#b48f1d\",\n    \"lyric.fontSize\": 54,\n    \"shortCut.enableLocal\": true,\n    \"shortCut.enableGlobal\": false,\n    \"download.concurrency\": 5,\n    \"normal.musicListColumnsShown\": [],\n    \"backup.resumeBehavior\": \"append\",\n    \"normal.language\": \"zh-CN\",\n};\n\n\nexport default _defaultAppConfig;\n"
  },
  {
    "path": "src/shared/app-config/main.ts",
    "content": "import path from \"path\";\nimport { app, ipcMain } from \"electron\";\nimport originalFs from \"fs\";\nimport fs from \"fs/promises\";\nimport { rimraf } from \"rimraf\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport logger from \"@shared/logger/main\";\nimport _defaultAppConfig from \"@shared/app-config/default-app-config\";\n\n\nclass AppConfig {\n    private _configPath: string;\n    private windowManager: IWindowManager;\n    private config: IAppConfig;\n\n    private onAppConfigUpdatedCallbacks = new Set<(patch: IAppConfig, config: IAppConfig, from: \"main\" | \"renderer\") => void>();\n\n    get configPath() {\n        if (!this._configPath) {\n            this._configPath = path.resolve(app.getPath(\"userData\"), \"config.json\");\n        }\n        return this._configPath;\n    }\n\n\n    private async checkPath() {\n        // 1. Check dir\n        const configDirPath = app.getPath(\"userData\");\n\n        try {\n            const res = await fs.stat(configDirPath);\n            if (!res.isDirectory()) {\n                await rimraf(configDirPath);\n                throw new Error(\"Not a valid path\");\n            }\n        } catch {\n            await fs.mkdir(configDirPath, {\n                recursive: true,\n            });\n        }\n\n        // 2. Check file\n        try {\n            const res = await fs.stat(this.configPath);\n            if (!res.isFile()) {\n                await rimraf(this.configPath);\n                throw new Error(\"Not a valid path\");\n            }\n        } catch {\n            await fs.writeFile(this.configPath, JSON.stringify(_defaultAppConfig, undefined, 4), \"utf-8\");\n        }\n    }\n\n    async setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n\n        await this.checkPath();\n        await this.loadConfig();\n\n        // Bind events\n        // sync config\n        ipcMain.handle(\"@shared/app-config/sync-app-config\", () => {\n            return this.config;\n        });\n\n        ipcMain.on(\"@shared/app-config/set-app-config\", (_rawEvt, data: IAppConfig) => {\n            /**\n             * data: {key: value}\n             */\n            this._setConfig(data, \"renderer\");\n        });\n\n        ipcMain.on(\"@shared/app-config/reset\", () => {\n            this.reset();\n        });\n    }\n\n    public onConfigUpdated(callback: (patch: IAppConfig, config: IAppConfig, from: \"main\" | \"renderer\") => void) {\n        this.onAppConfigUpdatedCallbacks.add(callback);\n    }\n\n    public offConfigUpdated(callback: (patch: IAppConfig, config: IAppConfig, from: \"main\" | \"renderer\") => void) {\n        this.onAppConfigUpdatedCallbacks.delete(callback);\n    }\n\n    async migrateOldVersionConfig() {\n        if (this.config[\"$schema-version\"] >= 0) {\n            return;\n        }\n        // 1. 升级到v1\n        try {\n            const oldConfig = this.config as any;\n            const newConfig: any = {\n                \"normal.closeBehavior\": oldConfig.normal?.closeBehavior === \"exit\" ? \"exit_app\" : oldConfig.normal?.closeBehavior,\n                \"normal.maxHistoryLength\": oldConfig.normal?.maxHistoryLength,\n                \"normal.checkUpdate\": oldConfig.normal?.checkUpdate,\n                \"normal.taskbarThumb\": oldConfig.normal?.taskbarThumb,\n                \"normal.musicListColumnsShown\": oldConfig.normal?.musicListColumnsShown,\n                \"normal.language\": oldConfig.normal?.language,\n\n                \"playMusic.caseSensitiveInSearch\": oldConfig.playMusic?.caseSensitiveInSearch,\n                \"playMusic.defaultQuality\": oldConfig.playMusic?.defaultQuality,\n                \"playMusic.whenQualityMissing\": oldConfig.playMusic?.whenQualityMissing,\n                \"playMusic.clickMusicList\": oldConfig.playMusic?.clickMusicList,\n                \"playMusic.playError\": oldConfig.playMusic?.playError,\n                \"playMusic.audioOutputDevice\": oldConfig.playMusic?.audioOutputDevice,\n                \"playMusic.whenDeviceRemoved\": oldConfig.playMusic?.whenDeviceRemoved,\n\n                \"lyric.enableStatusBarLyric\": oldConfig.lyric?.enableStatusBarLyric,\n                \"lyric.enableDesktopLyric\": oldConfig.lyric?.enableDesktopLyric,\n                \"lyric.alwaysOnTop\": oldConfig.lyric?.alwaysOnTop,\n                \"lyric.lockLyric\": oldConfig.lyric?.lockLyric,\n                \"lyric.fontData\": oldConfig.lyric?.fontData,\n                \"lyric.fontColor\": oldConfig.lyric?.fontColor,\n                \"lyric.fontSize\": oldConfig.lyric?.fontSize,\n                \"lyric.strokeColor\": oldConfig.lyric?.strokeColor,\n\n                \"shortCut.enableLocal\": oldConfig.shortCut?.enableLocal,\n                \"shortCut.enableGlobal\": oldConfig.shortCut?.enableGlobal,\n                \"shortCut.shortcuts\": {\n                    ...oldConfig.shortCut?.shortcuts,\n                    \"toggle-main-window-visible\": { local: null, global: null },\n                },\n\n                \"download.path\": oldConfig.download?.path,\n                \"download.defaultQuality\": oldConfig.download?.defaultQuality,\n                \"download.whenQualityMissing\": oldConfig.download?.whenQualityMissing,\n                \"download.concurrency\": oldConfig.download?.concurrency,\n\n                \"plugin.autoUpdatePlugin\": oldConfig.plugin?.autoUpdatePlugin,\n                \"plugin.notCheckPluginVersion\": oldConfig.plugin?.notCheckPluginVersion,\n\n                \"network.proxy.enabled\": oldConfig.network?.proxy?.enabled,\n                \"network.proxy.host\": oldConfig.network?.proxy?.host,\n                \"network.proxy.port\": oldConfig.network?.proxy?.port,\n                \"network.proxy.username\": oldConfig.network?.proxy?.username,\n                \"network.proxy.password\": oldConfig.network?.proxy?.password,\n\n                \"backup.resumeBehavior\": oldConfig.backup?.resumeBehavior,\n                \"backup.webdav.url\": oldConfig.backup?.webdav?.url,\n                \"backup.webdav.username\": oldConfig.backup?.webdav?.username,\n                \"backup.webdav.password\": oldConfig.backup?.webdav?.password,\n\n                \"localMusic.watchDir\": oldConfig.localMusic?.watchDir,\n\n                \"private.lyricWindowPosition\": oldConfig.private?.lyricWindowPosition,\n                \"private.minimodeWindowPosition\": oldConfig.private?.minimodeWindowPosition,\n                \"private.pluginMeta\": oldConfig.private?.pluginMeta,\n                \"private.minimode\": oldConfig.private?.minimode,\n            };\n            this.config = newConfig;\n            for (const k in _defaultAppConfig) {\n                if (newConfig[k] === null || newConfig[k] === undefined) {\n                    // @ts-ignore\n                    newConfig[k] = _defaultAppConfig[k];\n                }\n            }\n            const rawConfig = JSON.stringify(newConfig, undefined, 4);\n            originalFs.writeFileSync(this.configPath, rawConfig, \"utf-8\");\n        } catch (e) {\n            logger.logError(\"迁移旧版配置失败\", e);\n        }\n    }\n\n    async loadConfig() {\n        try {\n            if (this.config) {\n                return { ..._defaultAppConfig, ...this.config };\n            } else {\n                const rawConfig = await fs.readFile(this.configPath, \"utf8\");\n                this.config = JSON.parse(rawConfig);\n                // 升级旧版设置\n                await this.migrateOldVersionConfig();\n                this.config = {\n                    ..._defaultAppConfig,\n                    ...this.config,\n                };\n            }\n        } catch (e) {\n            if (e.message === \"Unexpected end of JSON input\" || e.code === \"EISDIR\") {\n                // JSON 解析异常 / 非文件\n                await rimraf(this.configPath);\n                await this.checkPath();\n            } else if (e.code === \"ENOENT\") {\n                // 文件不存在\n                await this.checkPath();\n            }\n            this.config = { ..._defaultAppConfig };\n        }\n        return this.config;\n    }\n\n    public getAllConfig() {\n        return this.config;\n    }\n\n    public reset() {\n        this.config = {};\n        this.setConfig({});\n    }\n\n    public getConfig<T extends keyof IAppConfig>(key: T): IAppConfig[T] {\n        return this.config[key];\n    }\n\n    public setConfig(data: IAppConfig) {\n        this._setConfig(data, \"main\");\n    }\n\n    private _setConfig(data: IAppConfig, from: \"main\" | \"renderer\") {\n        try {\n            // 1. Merge old one\n            this.config = { ..._defaultAppConfig, ...this.config, ...data };\n            // 2. Save to file\n            const rawConfig = JSON.stringify(this.config, undefined, 4);\n            originalFs.writeFileSync(this.configPath, rawConfig, \"utf-8\");\n            // 3. Notify to all windows\n            this.windowManager.getAllWindows().forEach((window) => {\n                window.webContents.send(\"@shared/app-config/update-app-config\", data);\n            });\n\n            this.onAppConfigUpdatedCallbacks.forEach((callback) => {\n                callback(data, this.config, from);\n            });\n\n        } catch (e) {\n            logger.logError(\"设置配置失败\", e);\n        }\n    }\n\n}\n\nexport default new AppConfig();\n"
  },
  {
    "path": "src/shared/app-config/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\n\n\nasync function syncConfig() {\n    return await ipcRenderer.invoke(\"@shared/app-config/sync-app-config\");\n}\n\nfunction setConfig(config: any) {\n    return ipcRenderer.send(\"@shared/app-config/set-app-config\", config);\n}\n\nfunction reset() {\n    return ipcRenderer.send(\"@shared/app-config/reset\");\n}\n\nfunction onConfigUpdate(callback: (patch: any) => void) {\n    ipcRenderer.on(\"@shared/app-config/update-app-config\", (_event, patch) => {\n        callback(patch);\n    });\n}\n\n\nconst mod = {\n    syncConfig,\n    setConfig,\n    onConfigUpdate,\n    reset,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/app-config\", mod);\n\n"
  },
  {
    "path": "src/shared/app-config/renderer.ts",
    "content": "import { IAppConfig } from \"@/types/app-config\";\nimport defaultAppConfig from \"@shared/app-config/default-app-config\";\n\n\ninterface IMod {\n    syncConfig(): Promise<IAppConfig>;\n\n    setConfig(config: IAppConfig): void;\n\n    onConfigUpdate(callback: (config: IAppConfig) => void): void;\n\n    reset(): void;\n}\n\nconst mod = window[\"@shared/app-config\" as any] as unknown as IMod;\n\nclass AppConfig {\n    private config: IAppConfig = {};\n\n    public initialized = false;\n\n    private updateCallbacks: Set<(patch: IAppConfig, config: IAppConfig) => void> = new Set();\n\n    private notifyCallbacks(patch: IAppConfig) {\n        for (const callback of this.updateCallbacks) {\n            callback(patch, this.config);\n        }\n    }\n\n    async setup() {\n        this.initialized = true;\n        this.config = await mod.syncConfig();\n        this.notifyCallbacks(this.config);\n\n        mod.onConfigUpdate((patch) => {\n            this.config = { ...defaultAppConfig, ...this.config, ...patch };\n            this.notifyCallbacks(patch);\n        });\n    }\n\n    public onConfigUpdate(callback: (patch: IAppConfig, config: IAppConfig) => void) {\n        this.updateCallbacks.add(callback);\n    }\n\n    public offConfigUpdate(callback: (patch: IAppConfig, config: IAppConfig) => void) {\n        this.updateCallbacks.delete(callback);\n    }\n\n    public getAllConfig() {\n        return this.config;\n    }\n\n    public getConfig<T extends keyof IAppConfig>(key: T): IAppConfig[T] {\n        return this.config[key];\n    }\n\n    public setConfig(data: IAppConfig) {\n        mod.setConfig(data);\n    }\n\n    public reset() {\n        mod.reset();\n        this.config = defaultAppConfig;\n        this.notifyCallbacks(this.config);\n    }\n\n}\n\nexport default new AppConfig();\n"
  },
  {
    "path": "src/shared/database/main.ts",
    "content": "export default {};"
  },
  {
    "path": "src/shared/database/preload-backup.ts",
    "content": "/**\n * 数据库模块\n * \n * 此模块负责加载数据库相关的功能，提供渲染进程需要的业务逻辑。\n */\n\n\nimport { app } from \"electron\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\n\n\nconst appDbPath = path.resolve(app.getPath(\"userData\"), \"./app-database/database.db\");\n\nconst database = new Database(appDbPath);\ndatabase.pragma(\"journal_mode = WAL\");\ndatabase.pragma(\"foreign_keys = ON\");      // 启用外键支持\ndatabase.pragma(\"synchronous = NORMAL\");   // WAL模式下推荐设置\n\n// 数据库版本号\nconst DATABASE_LATEST_VERSION = 1;\n\n// 创建初始表结构\nfunction createInitialTables() {\n    // 创建歌单表（IMusicSheetModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS LocalMusicSheets (\n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            title TEXT NOT NULL,\n            artwork TEXT,\n            description TEXT,\n            worksNum INTEGER DEFAULT 0,\n            playCount INTEGER DEFAULT 0,\n            createAt INTEGER,\n            artist TEXT,\n            _raw TEXT NOT NULL,     -- (存储原始JSON数据)\n            _sortIndex REAL, -- $$sortIndex\n            \n            -- 联合主键\n            PRIMARY KEY (platform, id)\n        );\n    `);\n\n    // 创建音乐项表（IMusicItemModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS LocalMusicItems (\n            _key INTEGER PRIMARY KEY AUTOINCREMENT,  \n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            artist TEXT NOT NULL,\n            title TEXT NOT NULL,\n            duration REAL,\n            album TEXT,\n            artwork TEXT,\n            _timestamp INTEGER NOT NULL, \n            _raw TEXT NOT NULL,        -- 替代 $$raw\n            _sortIndex REAL,\n            _musicSheetId TEXT NOT NULL,    -- 替代 $$musicSheetId\n            _musicSheetPlatform TEXT NOT NULL, -- 替代 $$musicSheetPlatform\n            \n            -- 添加复合唯一约束防止同一歌单重复添加相同歌曲\n            UNIQUE (_musicSheetPlatform, _musicSheetId, platform, id),\n            \n            -- 外键引用歌单表的联合主键\n            FOREIGN KEY (_musicSheetPlatform, _musicSheetId)\n            REFERENCES LocalMusicSheets(platform, id)\n            ON DELETE CASCADE\n            ON UPDATE CASCADE\n        );\n    `);\n\n    // 创建索引优化查询性能\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_coreid ON LocalMusicItems(platform, id)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_sheet ON LocalMusicItems(_musicSheetPlatform, _musicSheetId)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_sheets_platform ON LocalMusicSheets(platform)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_artist ON LocalMusicItems(artist)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_sheets_sort ON LocalMusicSheets(_sortIndex)\");\n\n    // 创建star的歌单表（IMusicSheetModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS StarredMusicSheets (\n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            title TEXT NOT NULL,\n            artwork TEXT,\n            description TEXT,\n            worksNum INTEGER DEFAULT 0,\n            playCount INTEGER DEFAULT 0,\n            createAt INTEGER,\n            artist TEXT,\n            _raw TEXT NOT NULL,     -- $$raw (存储原始JSON数据)\n            _sortIndex REAL, -- $$sortIndex\n            \n            -- 联合主键\n            PRIMARY KEY (platform, id)\n        );\n    `);\n}\n\nfunction migrateDatabase() {\n    let currentVersion = database.pragma(\"user_version\", { simple: true }) as number;\n\n    if (currentVersion >= DATABASE_LATEST_VERSION) {\n        return;\n    }\n    if (!currentVersion) {\n        currentVersion = 0;\n    }\n\n    // 在事务中执行升级\n    const upgrade = database.transaction(() => {\n        for (let version = currentVersion + 1; version <= DATABASE_LATEST_VERSION; version++) {\n            switch (version) {\n                case 1:\n                    createInitialTables();\n                    break;\n                // 未来的版本升级可以在这里添加\n                // case 2:\n                //     upgradeToVersion2();\n                //     break;\n                default:\n                    throw new Error(`Unknown database version: ${version}`);\n            }\n\n            database.pragma(`user_version = ${version}`);\n        }\n    });\n\n    upgrade();\n\n}\n\nmigrateDatabase();\n\n\n//////////////////// 歌单增删查改\n\nclass LocalMusicSheetDB {\n    private static readonly SORT_BASE = 1000; // 排序基础值\n    private static readonly SORT_INCREMENT = 1000; // 排序增量\n    private static readonly MIN_SORT_INTERVAL = 0.000001; // 最小排序间隔，低于此值需要重新均衡\n\n    /**\n     * 添加歌单\n     * @param musicSheet 歌单数据\n     * @returns 是否添加成功\n     */\n    static addMusicSheet(musicSheet: IDataBaseModel.IMusicSheetModel): boolean {\n        try {\n            const insertStmt = database.prepare(`\n                INSERT INTO LocalMusicSheets \n                (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex)\n                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            `);\n\n            // 如果没有指定排序索引，设置为当前最大值+增量\n            let sortIndex = musicSheet._sortIndex;\n            if (sortIndex === undefined || sortIndex === null) {\n                const maxSortStmt = database.prepare(\"SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets\");\n                const result = maxSortStmt.get() as { maxSort: number | null };\n                sortIndex = (result.maxSort || 0) + this.SORT_INCREMENT;\n            }\n\n            const result = insertStmt.run(\n                musicSheet.platform,\n                musicSheet.id,\n                musicSheet.title,\n                musicSheet.artwork || null,\n                musicSheet.description || null,\n                musicSheet.worksNum || 0,\n                musicSheet.playCount || 0,\n                musicSheet.createAt || Date.now(),\n                musicSheet.artist || null,\n                musicSheet._raw,\n                sortIndex,\n            );\n\n            return result.changes > 0;\n        } catch (error) {\n            console.error(\"添加歌单失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 批量添加歌单\n     * @param musicSheets 歌单数据数组\n     * @returns 成功添加的数量\n     */\n    static batchAddMusicSheets(musicSheets: IDataBaseModel.IMusicSheetModel[]): number {\n        if (!musicSheets.length) return 0;\n\n        try {\n            const transaction = database.transaction(() => {\n                let successCount = 0;\n\n                // 获取当前最大排序值\n                const maxSortStmt = database.prepare(\"SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets\");\n                const result = maxSortStmt.get() as { maxSort: number | null };\n                let currentMaxSort = result.maxSort || 0;\n\n                const insertStmt = database.prepare(`\n                    INSERT INTO LocalMusicSheets \n                    (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex)\n                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n                `);\n\n                for (const sheet of musicSheets) {\n                    try {\n                        let sortIndex = sheet._sortIndex;\n                        if (sortIndex === undefined || sortIndex === null) {\n                            currentMaxSort += this.SORT_INCREMENT;\n                            sortIndex = currentMaxSort;\n                        }\n\n                        const insertResult = insertStmt.run(\n                            sheet.platform,\n                            sheet.id,\n                            sheet.title,\n                            sheet.artwork || null,\n                            sheet.description || null,\n                            sheet.worksNum || 0,\n                            sheet.playCount || 0,\n                            sheet.createAt || Date.now(),\n                            sheet.artist || null,\n                            sheet._raw,\n                            sortIndex,\n                        );\n\n                        if (insertResult.changes > 0) {\n                            successCount++;\n                        }\n                    } catch (error) {\n                        console.warn(`批量添加歌单失败 (${sheet.platform}:${sheet.id}):`, error);\n                    }\n                }\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch (error) {\n            console.error(\"批量添加歌单失败:\", error);\n            return 0;\n        }\n    }\n\n    /**\n     * 删除歌单\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 是否删除成功\n     */\n    static deleteMusicSheet(platform: string, id: string): boolean {\n        try {\n            const deleteStmt = database.prepare(\"DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n            const result = deleteStmt.run(platform, id);\n            return result.changes > 0;\n        } catch (error) {\n            console.error(\"删除歌单失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 批量删除歌单\n     * @param sheets 要删除的歌单标识数组 {platform, id}\n     * @returns 成功删除的数量\n     */\n    static batchDeleteMusicSheets(sheets: Array<{ platform: string; id: string }>): number {\n        if (!sheets.length) return 0;\n\n        try {\n            const transaction = database.transaction(() => {\n                let successCount = 0;\n                const deleteStmt = database.prepare(\"DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n\n                for (const sheet of sheets) {\n                    try {\n                        const result = deleteStmt.run(sheet.platform, sheet.id);\n                        if (result.changes > 0) {\n                            successCount++;\n                        }\n                    } catch (error) {\n                        console.warn(`批量删除歌单失败 (${sheet.platform}:${sheet.id}):`, error);\n                    }\n                }\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch (error) {\n            console.error(\"批量删除歌单失败:\", error);\n            return 0;\n        }\n    }\n\n    /**\n     * 查询单个歌单\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 歌单数据或null\n     */\n    static getMusicSheet(platform: string, id: string): IDataBaseModel.IMusicSheetModel | null {\n        try {\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE platform = ? AND id = ?\n            `);\n            const result = selectStmt.get(platform, id) as any;\n\n            if (!result) return null;\n\n            return {\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            };\n        } catch (error) {\n            console.error(\"查询歌单失败:\", error);\n            return null;\n        }\n    }\n\n    /**\n     * 查询所有歌单\n     * @param orderBy 排序字段，默认按_sortIndex排序\n     * @param order 排序方向，'ASC' 或 'DESC'，默认ASC\n     * @returns 歌单数组\n     */\n    static getAllMusicSheets(orderBy: string = \"_sortIndex\", order: \"ASC\" | \"DESC\" = \"ASC\"): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            // 验证排序字段，防止SQL注入\n            const allowedFields = [\"_sortIndex\", \"title\", \"createAt\", \"artist\", \"playCount\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all() as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));\n        } catch (error) {\n            console.error(\"查询所有歌单失败:\", error);\n            return [];\n        }\n    }\n\n    /**\n     * 按平台查询歌单\n     * @param platform 平台名称\n     * @param orderBy 排序字段\n     * @param order 排序方向\n     * @returns 歌单数组\n     */\n    static getMusicSheetsByPlatform(platform: string, orderBy: string = \"_sortIndex\", order: \"ASC\" | \"DESC\" = \"ASC\"): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            const allowedFields = [\"_sortIndex\", \"title\", \"createAt\", \"artist\", \"playCount\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE platform = ?\n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all(platform) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));\n        } catch (error) {\n            console.error(\"按平台查询歌单失败:\", error);\n            return [];\n        }\n    }\n\n    /**\n     * 更新歌单（部分更新）\n     * @param platform 平台\n     * @param id 歌单ID\n     * @param updates 要更新的字段\n     * @returns 是否更新成功\n     */\n    static updateMusicSheet(\n        platform: string,\n        id: string,\n        updates: Partial<Omit<IDataBaseModel.IMusicSheetModel, \"platform\" | \"id\">>,\n    ): boolean {\n        try {\n            if (Object.keys(updates).length === 0) {\n                return false;\n            }\n\n            // 构建动态更新SQL\n            const allowedFields = [\"title\", \"artwork\", \"description\", \"worksNum\", \"playCount\", \"createAt\", \"artist\", \"_raw\", \"_sortIndex\"];\n            const updateFields: string[] = [];\n            const values: any[] = [];\n\n            for (const [key, value] of Object.entries(updates)) {\n                if (allowedFields.includes(key) && value !== undefined) {\n                    updateFields.push(`${key} = ?`);\n                    values.push(value);\n                }\n            }\n\n            if (updateFields.length === 0) {\n                return false;\n            }\n\n            values.push(platform, id);\n            const updateStmt = database.prepare(`\n                UPDATE LocalMusicSheets \n                SET ${updateFields.join(\", \")} \n                WHERE platform = ? AND id = ?\n            `);\n\n            const result = updateStmt.run(...values);\n            return result.changes > 0;\n        } catch (error) {\n            console.error(\"更新歌单失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 批量更新歌单\n     * @param updates 更新数据数组，每个元素包含 platform, id 和要更新的字段\n     * @returns 成功更新的数量\n     */\n    static batchUpdateMusicSheets(\n        updates: Array<{\n            platform: string;\n            id: string;\n            data: Partial<Omit<IDataBaseModel.IMusicSheetModel, \"platform\" | \"id\">>;\n        }>,\n    ): number {\n        if (!updates.length) return 0;\n\n        try {\n            const transaction = database.transaction(() => {\n                let successCount = 0;\n\n                for (const update of updates) {\n                    try {\n                        if (this.updateMusicSheet(update.platform, update.id, update.data)) {\n                            successCount++;\n                        }\n                    } catch (error) {\n                        console.warn(`批量更新歌单失败 (${update.platform}:${update.id}):`, error);\n                    }\n                }\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch (error) {\n            console.error(\"批量更新歌单失败:\", error);\n            return 0;\n        }\n    }\n\n    /**\n     * 更新歌单排序\n     * @param platform 平台\n     * @param id 歌单ID\n     * @param newSortIndex 新的排序索引\n     * @returns 是否更新成功\n     */\n    static updateMusicSheetSort(platform: string, id: string, newSortIndex: number): boolean {\n        try {\n            const updateStmt = database.prepare(`\n                UPDATE LocalMusicSheets \n                SET _sortIndex = ? \n                WHERE platform = ? AND id = ?\n            `);\n            const result = updateStmt.run(newSortIndex, platform, id);\n            return result.changes > 0;\n        } catch (error) {\n            console.error(\"更新歌单排序失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 在两个歌单之间插入新歌单（使用浮点数排序法）\n     * @param platform 平台\n     * @param id 歌单ID\n     * @param afterPlatform 插入位置前一个歌单的平台（null表示插入到开头）\n     * @param afterId 插入位置前一个歌单的ID（null表示插入到开头）\n     * @param beforePlatform 插入位置后一个歌单的平台（null表示插入到末尾）\n     * @param beforeId 插入位置后一个歌单的ID（null表示插入到末尾）\n     * @returns 是否更新成功\n     */\n    static insertMusicSheetBetween(\n        platform: string,\n        id: string,\n        afterPlatform: string | null,\n        afterId: string | null,\n        beforePlatform: string | null,\n        beforeId: string | null,\n    ): boolean {\n        try {\n            let newSortIndex: number;\n\n            if (!afterPlatform && !afterId) {\n                // 插入到开头\n                const firstStmt = database.prepare(\"SELECT MIN(_sortIndex) as minSort FROM LocalMusicSheets\");\n                const result = firstStmt.get() as { minSort: number | null };\n                newSortIndex = (result.minSort || this.SORT_BASE) - this.SORT_INCREMENT;\n            } else if (!beforePlatform && !beforeId) {\n                // 插入到末尾\n                const lastStmt = database.prepare(\"SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets\");\n                const result = lastStmt.get() as { maxSort: number | null };\n                newSortIndex = (result.maxSort || this.SORT_BASE) + this.SORT_INCREMENT;\n            } else {\n                // 插入到中间\n                const afterStmt = database.prepare(\"SELECT _sortIndex FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n                const beforeStmt = database.prepare(\"SELECT _sortIndex FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n\n                const afterResult = afterStmt.get(afterPlatform, afterId) as { _sortIndex: number } | undefined;\n                const beforeResult = beforeStmt.get(beforePlatform, beforeId) as { _sortIndex: number } | undefined;\n\n                if (!afterResult || !beforeResult) {\n                    return false;\n                }\n\n                newSortIndex = (afterResult._sortIndex + beforeResult._sortIndex) / 2;\n\n                // 检查是否需要重新均衡\n                if (Math.abs(beforeResult._sortIndex - afterResult._sortIndex) < this.MIN_SORT_INTERVAL) {\n                    this.rebalanceSortIndexes();\n                    // 重新计算新的排序值\n                    const newAfterResult = afterStmt.get(afterPlatform, afterId) as { _sortIndex: number } | undefined;\n                    const newBeforeResult = beforeStmt.get(beforePlatform, beforeId) as { _sortIndex: number } | undefined;\n                    if (newAfterResult && newBeforeResult) {\n                        newSortIndex = (newAfterResult._sortIndex + newBeforeResult._sortIndex) / 2;\n                    }\n                }\n            }\n\n            return this.updateMusicSheetSort(platform, id, newSortIndex);\n        } catch (error) {\n            console.error(\"插入歌单排序失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 重新均衡所有歌单的排序索引\n     * 当排序间隔过小时调用此方法\n     */\n    static rebalanceSortIndexes(): boolean {\n        try {\n            const transaction = database.transaction(() => {\n                // 获取所有歌单按当前排序\n                const selectStmt = database.prepare(\"SELECT platform, id FROM LocalMusicSheets ORDER BY _sortIndex ASC\");\n                const sheets = selectStmt.all() as Array<{ platform: string; id: string }>;\n\n                const updateStmt = database.prepare(\"UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?\");\n\n                // 重新分配排序索引，每个间隔1000\n                sheets.forEach((sheet, index) => {\n                    const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT);\n                    updateStmt.run(newSortIndex, sheet.platform, sheet.id);\n                });\n\n                return true;\n            });\n\n            return transaction();\n        } catch (error) {\n            console.error(\"重新均衡排序索引失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 获取歌单中的所有歌曲\n     * @param platform 歌单平台\n     * @param id 歌单ID\n     * @param orderBy 排序字段，默认按_sortIndex排序\n     * @param order 排序方向\n     * @returns 歌曲数组\n     */\n    static getMusicItemsInSheet(\n        platform: string,\n        id: string,\n        orderBy: string = \"_sortIndex\",\n        order: \"ASC\" | \"DESC\" = \"ASC\",\n    ): IDataBaseModel.IMusicItemModel[] {\n        try {\n            const allowedFields = [\"_sortIndex\", \"title\", \"artist\", \"album\", \"_timestamp\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicItems \n                WHERE _musicSheetPlatform = ? AND _musicSheetId = ?\n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all(platform, id) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                artist: result.artist,\n                title: result.title,\n                duration: result.duration,\n                album: result.album,\n                artwork: result.artwork,\n                _timestamp: result._timestamp,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n                _musicSheetId: result._musicSheetId,\n                _musicSheetPlatform: result._musicSheetPlatform,\n            }));\n        } catch (error) {\n            console.error(\"获取歌单歌曲失败:\", error);\n            return [];\n        }\n    }\n\n    /**\n     * 获取歌单中歌曲的数量\n     * @param platform 歌单平台\n     * @param id 歌单ID\n     * @returns 歌曲数量\n     */\n    static getMusicItemCountInSheet(platform: string, id: string): number {\n        try {\n            const countStmt = database.prepare(`\n                SELECT COUNT(*) as count FROM LocalMusicItems \n                WHERE _musicSheetPlatform = ? AND _musicSheetId = ?\n            `);\n            const result = countStmt.get(platform, id) as { count: number };\n            return result.count;\n        } catch (error) {\n            console.error(\"获取歌单歌曲数量失败:\", error);\n            return 0;\n        }\n    }\n\n    /**\n     * 检查歌单是否存在\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 是否存在\n     */\n    static existsMusicSheet(platform: string, id: string): boolean {\n        try {\n            const countStmt = database.prepare(\"SELECT COUNT(*) as count FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n            const result = countStmt.get(platform, id) as { count: number };\n            return result.count > 0;\n        } catch (error) {\n            console.error(\"检查歌单是否存在失败:\", error);\n            return false;\n        }\n    }\n\n    /**\n     * 搜索歌单\n     * @param keyword 搜索关键词\n     * @param searchFields 搜索字段数组，默认搜索title和artist\n     * @returns 匹配的歌单数组\n     */\n    static searchMusicSheets(\n        keyword: string,\n        searchFields: string[] = [\"title\", \"artist\"],\n    ): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            if (!keyword.trim()) {\n                return this.getAllMusicSheets();\n            }\n\n            const allowedFields = [\"title\", \"artist\", \"description\"];\n            const validFields = searchFields.filter(field => allowedFields.includes(field));\n\n            if (validFields.length === 0) {\n                validFields.push(\"title\");\n            }\n\n            const whereConditions = validFields.map(field => `${field} LIKE ?`).join(\" OR \");\n            const searchPattern = `%${keyword}%`;\n            const params = validFields.map(() => searchPattern);\n\n            const searchStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE ${whereConditions}\n                ORDER BY _sortIndex ASC\n            `);\n\n            const results = searchStmt.all(...params) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));        \n        } catch (error) {\n            console.error(\"搜索歌单失败:\", error);\n            return [];\n        }\n    }\n\n    /**\n     * 批量移动歌单到指定位置（支持所有拖拽和排序场景）\n     * @param selectedSheets 要移动的歌单标识数组\n     * @param targetPlatform 目标歌单的平台（null表示移动到开头/末尾）\n     * @param targetId 目标歌单的ID（null表示移动到开头/末尾）  \n     * @param position 相对于目标歌单的位置：\"before\" | \"after\"，默认\"after\"\n     * @returns 成功移动的数量\n     * \n     * @example\n     * // 移动到开头\n     * batchMoveMusicSheets(sheets, null, null, \"before\")\n     * \n     * // 移动到末尾  \n     * batchMoveMusicSheets(sheets, null, null, \"after\")\n     * \n     * // 移动到指定歌单之前\n     * batchMoveMusicSheets(sheets, \"platform1\", \"id1\", \"before\")\n     * \n     * // 移动到指定歌单之后\n     * batchMoveMusicSheets(sheets, \"platform1\", \"id1\", \"after\")\n     */\n    static batchMoveMusicSheets(\n        selectedSheets: Array<{ platform: string; id: string }>,\n        targetPlatform: string | null = null,\n        targetId: string | null = null,        position: \"before\" | \"after\" = \"after\",\n    ): number {\n        if (!selectedSheets.length) return 0;\n\n        try {\n            const transaction = database.transaction(() => {\n                // 获取所有歌单的排序信息\n                const allSheetsStmt = database.prepare(`\n                    SELECT platform, id, _sortIndex \n                    FROM LocalMusicSheets \n                    ORDER BY _sortIndex ASC\n                `);\n                const allSheets = allSheetsStmt.all() as Array<{\n                    platform: string;\n                    id: string;\n                    _sortIndex: number;\n                }>;\n\n                // 分离要移动的歌单和剩余的歌单\n                const selectedSheetIds = new Set(selectedSheets.map(s => `${s.platform}:${s.id}`));\n                const sheetsToMove = allSheets.filter(s => selectedSheetIds.has(`${s.platform}:${s.id}`));\n                const remainingSheets = allSheets.filter(s => !selectedSheetIds.has(`${s.platform}:${s.id}`));\n\n                if (!sheetsToMove.length) return 0;\n\n                // 计算插入位置\n                let insertIndex = 0;\n                if (targetPlatform && targetId) {\n                    const targetIndex = remainingSheets.findIndex(s => \n                        s.platform === targetPlatform && s.id === targetId,\n                    );\n                    insertIndex = targetIndex === -1 ? 0 : \n                        (position === \"after\" ? targetIndex + 1 : targetIndex);\n                } else {\n                    insertIndex = position === \"before\" ? 0 : remainingSheets.length;\n                }\n\n                // 构建新的排序数组并重新分配排序索引\n                const newOrderedSheets = [...remainingSheets];\n                newOrderedSheets.splice(insertIndex, 0, ...sheetsToMove);\n\n                const updateStmt = database.prepare(`\n                    UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?\n                `);\n\n                let successCount = 0;\n                newOrderedSheets.forEach((sheet, index) => {\n                    const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT);\n                    try {\n                        const result = updateStmt.run(newSortIndex, sheet.platform, sheet.id);\n                        if (result.changes > 0 && selectedSheetIds.has(`${sheet.platform}:${sheet.id}`)) {\n                            successCount++;\n                        }\n                    } catch (error) {\n                        console.warn(`批量移动歌单失败 (${sheet.platform}:${sheet.id}):`, error);\n                    }\n                });\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch (error) {\n            console.error(\"批量移动歌单失败:\", error);\n            return 0;\n        }    \n    }\n}\n\n// 导出数据库实例和类\nexport { database, LocalMusicSheetDB };\n\n\n\n"
  },
  {
    "path": "src/shared/database/preload.ts",
    "content": "/**\n * 数据库模块\n * \n * 此模块负责加载数据库相关的功能，提供渲染进程需要的业务逻辑。\n */\n\nimport { app } from \"electron\";\nimport path from \"node:path\";\nimport Database from \"better-sqlite3\";\n\nconst appDbPath = path.resolve(app.getPath(\"userData\"), \"./app-database/database.db\");\n\nconst database = new Database(appDbPath);\ndatabase.pragma(\"journal_mode = WAL\");\ndatabase.pragma(\"foreign_keys = ON\");      // 启用外键支持\ndatabase.pragma(\"synchronous = NORMAL\");   // WAL模式下推荐设置\n\n// 数据库版本号\nconst DATABASE_LATEST_VERSION = 1;\n\n// 创建初始表结构\nfunction createInitialTables() {\n    // 创建歌单表（IMusicSheetModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS LocalMusicSheets (\n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            title TEXT NOT NULL,\n            artwork TEXT,\n            description TEXT,\n            worksNum INTEGER DEFAULT 0,\n            playCount INTEGER DEFAULT 0,\n            createAt INTEGER,\n            artist TEXT,\n            _raw TEXT NOT NULL,     -- (存储原始JSON数据)\n            _sortIndex REAL, -- $$sortIndex\n            \n            -- 联合主键\n            PRIMARY KEY (platform, id)\n        );\n    `);\n\n    // 创建音乐项表（IMusicItemModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS LocalMusicItems (\n            _key INTEGER PRIMARY KEY AUTOINCREMENT,  \n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            artist TEXT NOT NULL,\n            title TEXT NOT NULL,\n            duration REAL,\n            album TEXT,\n            artwork TEXT,\n            _timestamp INTEGER NOT NULL, \n            _raw TEXT NOT NULL,        -- 替代 $$raw\n            _sortIndex REAL,\n            _musicSheetId TEXT NOT NULL,    -- 替代 $$musicSheetId\n            _musicSheetPlatform TEXT NOT NULL, -- 替代 $$musicSheetPlatform\n            \n            -- 添加复合唯一约束防止同一歌单重复添加相同歌曲\n            UNIQUE (_musicSheetPlatform, _musicSheetId, platform, id),\n            \n            -- 外键引用歌单表的联合主键\n            FOREIGN KEY (_musicSheetPlatform, _musicSheetId)\n            REFERENCES LocalMusicSheets(platform, id)\n            ON DELETE CASCADE\n            ON UPDATE CASCADE\n        );\n    `);\n\n    // 创建索引优化查询性能\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_coreid ON LocalMusicItems(platform, id)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_sheet ON LocalMusicItems(_musicSheetPlatform, _musicSheetId)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_sheets_platform ON LocalMusicSheets(platform)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_items_artist ON LocalMusicItems(artist)\");\n    database.exec(\"CREATE INDEX IF NOT EXISTS idx_sheets_sort ON LocalMusicSheets(_sortIndex)\");\n\n    // 创建star的歌单表（IMusicSheetModel）\n    database.exec(`\n        CREATE TABLE IF NOT EXISTS StarredMusicSheets (\n            platform TEXT NOT NULL,\n            id TEXT NOT NULL,\n            title TEXT NOT NULL,\n            artwork TEXT,\n            description TEXT,\n            worksNum INTEGER DEFAULT 0,\n            playCount INTEGER DEFAULT 0,\n            createAt INTEGER,\n            artist TEXT,\n            _raw TEXT NOT NULL,     -- $$raw (存储原始JSON数据)\n            _sortIndex REAL, -- $$sortIndex\n            \n            -- 联合主键\n            PRIMARY KEY (platform, id)\n        );\n    `);\n}\n\nfunction migrateDatabase() {\n    let currentVersion = database.pragma(\"user_version\", { simple: true }) as number;\n\n    if (currentVersion >= DATABASE_LATEST_VERSION) {\n        return;\n    }\n    if (!currentVersion) {\n        currentVersion = 0;\n    }\n\n    // 在事务中执行升级\n    const upgrade = database.transaction(() => {\n        for (let version = currentVersion + 1; version <= DATABASE_LATEST_VERSION; version++) {\n            switch (version) {\n                case 1:\n                    createInitialTables();\n                    break;\n                // 未来的版本升级可以在这里添加\n                // case 2:\n                //     upgradeToVersion2();\n                //     break;\n                default:\n                    throw new Error(`Unknown database version: ${version}`);\n            }\n\n            database.pragma(`user_version = ${version}`);\n        }\n    });\n\n    upgrade();\n}\n\nmigrateDatabase();\n\n//////////////////// 歌单增删查改\n\nclass LocalMusicSheetDB {\n    private static readonly SORT_BASE = 1000; // 排序基础值\n    private static readonly SORT_INCREMENT = 1000; // 排序增量\n\n    /**\n     * 添加歌单\n     * @param musicSheet 歌单数据\n     * @returns 是否添加成功\n     */\n    static addMusicSheet(musicSheet: IDataBaseModel.IMusicSheetModel): boolean {\n        try {\n            const insertStmt = database.prepare(`\n                INSERT INTO LocalMusicSheets \n                (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex)\n                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n            `);\n\n            // 如果没有指定排序索引，设置为当前最大值+增量\n            let sortIndex = musicSheet._sortIndex;\n            if (sortIndex === undefined || sortIndex === null) {\n                const maxSortStmt = database.prepare(\"SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets\");\n                const result = maxSortStmt.get() as { maxSort: number | null };\n                sortIndex = (result.maxSort || 0) + this.SORT_INCREMENT;\n            }\n\n            const result = insertStmt.run(\n                musicSheet.platform,\n                musicSheet.id,\n                musicSheet.title,\n                musicSheet.artwork || null,\n                musicSheet.description || null,\n                musicSheet.worksNum || 0,\n                musicSheet.playCount || 0,\n                musicSheet.createAt || Date.now(),\n                musicSheet.artist || null,\n                musicSheet._raw,\n                sortIndex,\n            );\n\n            return result.changes > 0;\n        } catch {\n            return false;\n        }\n    }\n\n    /**\n     * 批量添加歌单\n     * @param musicSheets 歌单数据数组\n     * @returns 成功添加的数量\n     */\n    static batchAddMusicSheets(musicSheets: IDataBaseModel.IMusicSheetModel[]): number {\n        if (!musicSheets.length) {\n            return 0;\n        }\n\n        try {\n            const transaction = database.transaction(() => {\n                let successCount = 0;\n\n                // 获取当前最大排序值\n                const maxSortStmt = database.prepare(\"SELECT MAX(_sortIndex) as maxSort FROM LocalMusicSheets\");\n                const result = maxSortStmt.get() as { maxSort: number | null };\n                let currentMaxSort = result.maxSort || 0;\n\n                const insertStmt = database.prepare(`\n                    INSERT INTO LocalMusicSheets \n                    (platform, id, title, artwork, description, worksNum, playCount, createAt, artist, _raw, _sortIndex)\n                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n                `);\n\n                for (const sheet of musicSheets) {\n                    try {\n                        let sortIndex = sheet._sortIndex;\n                        if (sortIndex === undefined || sortIndex === null) {\n                            currentMaxSort += this.SORT_INCREMENT;\n                            sortIndex = currentMaxSort;\n                        }\n\n                        const insertResult = insertStmt.run(\n                            sheet.platform,\n                            sheet.id,\n                            sheet.title,\n                            sheet.artwork || null,\n                            sheet.description || null,\n                            sheet.worksNum || 0,\n                            sheet.playCount || 0,\n                            sheet.createAt || Date.now(),\n                            sheet.artist || null,\n                            sheet._raw,\n                            sortIndex,\n                        );\n\n                        if (insertResult.changes > 0) {\n                            successCount++;\n                        }\n                    } catch {\n                        // 忽略单个添加失败的情况\n                    }\n                }\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch {\n            return 0;\n        }\n    }\n\n    /**\n     * 删除歌单\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 是否删除成功\n     */\n    static deleteMusicSheet(platform: string, id: string): boolean {\n        try {\n            const deleteStmt = database.prepare(\"DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n            const result = deleteStmt.run(platform, id);\n            return result.changes > 0;\n        } catch {\n            return false;\n        }\n    }\n\n    /**\n     * 批量删除歌单\n     * @param sheets 要删除的歌单标识数组 {platform, id}\n     * @returns 成功删除的数量\n     */\n    static batchDeleteMusicSheets(sheets: Array<{ platform: string; id: string }>): number {\n        if (!sheets.length) {\n            return 0;\n        }\n\n        try {\n            const transaction = database.transaction(() => {\n                let successCount = 0;\n                const deleteStmt = database.prepare(\"DELETE FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n\n                for (const sheet of sheets) {\n                    try {\n                        const result = deleteStmt.run(sheet.platform, sheet.id);\n                        if (result.changes > 0) {\n                            successCount++;\n                        }\n                    } catch {\n                        // 忽略单个删除失败的情况\n                    }\n                }\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch {\n            return 0;\n        }\n    }\n\n    /**\n     * 查询单个歌单\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 歌单数据或null\n     */\n    static getMusicSheet(platform: string, id: string): IDataBaseModel.IMusicSheetModel | null {\n        try {\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE platform = ? AND id = ?\n            `);\n            const result = selectStmt.get(platform, id) as any;\n\n            if (!result) {\n                return null;\n            }\n\n            return {\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            };\n        } catch {\n            return null;\n        }\n    }\n\n    /**\n     * 查询所有歌单\n     * @param orderBy 排序字段，默认按_sortIndex排序\n     * @param order 排序方向，'ASC' 或 'DESC'，默认ASC\n     * @returns 歌单数组\n     */\n    static getAllMusicSheets(orderBy: string = \"_sortIndex\", order: \"ASC\" | \"DESC\" = \"ASC\"): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            // 验证排序字段，防止SQL注入\n            const allowedFields = [\"_sortIndex\", \"title\", \"createAt\", \"artist\", \"playCount\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all() as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));\n        } catch {\n            return [];\n        }\n    }\n\n    /**\n     * 按平台查询歌单\n     * @param platform 平台名称\n     * @param orderBy 排序字段\n     * @param order 排序方向\n     * @returns 歌单数组\n     */\n    static getMusicSheetsByPlatform(platform: string, orderBy: string = \"_sortIndex\", order: \"ASC\" | \"DESC\" = \"ASC\"): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            const allowedFields = [\"_sortIndex\", \"title\", \"createAt\", \"artist\", \"playCount\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE platform = ?\n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all(platform) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));\n        } catch {\n            return [];\n        }\n    }\n\n    /**\n     * 更新歌单（部分更新）\n     * @param platform 平台\n     * @param id 歌单ID\n     * @param updates 要更新的字段\n     * @returns 是否更新成功\n     */\n    static updateMusicSheet(\n        platform: string,\n        id: string,\n        updates: Partial<Omit<IDataBaseModel.IMusicSheetModel, \"platform\" | \"id\">>,\n    ): boolean {\n        try {\n            if (Object.keys(updates).length === 0) {\n                return false;\n            }\n\n            // 构建动态更新SQL\n            const allowedFields = [\"title\", \"artwork\", \"description\", \"worksNum\", \"playCount\", \"createAt\", \"artist\", \"_raw\", \"_sortIndex\"];\n            const updateFields: string[] = [];\n            const values: any[] = [];\n\n            for (const [key, value] of Object.entries(updates)) {\n                if (allowedFields.includes(key) && value !== undefined) {\n                    updateFields.push(`${key} = ?`);\n                    values.push(value);\n                }\n            }\n\n            if (updateFields.length === 0) {\n                return false;\n            }\n\n            values.push(platform, id);\n            const updateStmt = database.prepare(`\n                UPDATE LocalMusicSheets \n                SET ${updateFields.join(\", \")} \n                WHERE platform = ? AND id = ?\n            `);\n\n            const result = updateStmt.run(...values);\n            return result.changes > 0;\n        } catch {\n            return false;\n        }\n    }\n\n    /**\n     * 重新均衡所有歌单的排序索引\n     * 当排序间隔过小时调用此方法\n     */\n    static rebalanceSortIndexes(): boolean {\n        try {\n            const transaction = database.transaction(() => {\n                // 获取所有歌单按当前排序\n                const selectStmt = database.prepare(\"SELECT platform, id FROM LocalMusicSheets ORDER BY _sortIndex ASC\");\n                const sheets = selectStmt.all() as Array<{ platform: string; id: string }>;\n\n                const updateStmt = database.prepare(\"UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?\");\n\n                // 重新分配排序索引，每个间隔1000\n                sheets.forEach((sheet, index) => {\n                    const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT);\n                    updateStmt.run(newSortIndex, sheet.platform, sheet.id);\n                });\n\n                return true;\n            });\n\n            return transaction();\n        } catch {\n            return false;\n        }\n    }\n\n    /**\n     * 批量移动歌单到指定位置（支持所有拖拽和排序场景）\n     * @param selectedSheets 要移动的歌单标识数组\n     * @param targetPlatform 目标歌单的平台（null表示移动到开头/末尾）\n     * @param targetId 目标歌单的ID（null表示移动到开头/末尾）  \n     * @param position 相对于目标歌单的位置：\"before\" | \"after\"，默认\"after\"\n     * @returns 成功移动的数量\n     * \n     * @example\n     * // 移动到开头\n     * batchMoveMusicSheets(sheets, null, null, \"before\")\n     * \n     * // 移动到末尾  \n     * batchMoveMusicSheets(sheets, null, null, \"after\")\n     * \n     * // 移动到指定歌单之前\n     * batchMoveMusicSheets(sheets, \"platform1\", \"id1\", \"before\")\n     * \n     * // 移动到指定歌单之后\n     * batchMoveMusicSheets(sheets, \"platform1\", \"id1\", \"after\")\n     */\n    static batchMoveMusicSheets(\n        selectedSheets: Array<{ platform: string; id: string }>,\n        targetPlatform: string | null = null,\n        targetId: string | null = null,\n        position: \"before\" | \"after\" = \"after\",\n    ): number {\n        if (!selectedSheets.length) {\n            return 0;\n        }\n\n        try {\n            const transaction = database.transaction(() => {\n                // 获取所有歌单的排序信息\n                const allSheetsStmt = database.prepare(`\n                    SELECT platform, id, _sortIndex \n                    FROM LocalMusicSheets \n                    ORDER BY _sortIndex ASC\n                `);\n                const allSheets = allSheetsStmt.all() as Array<{\n                    platform: string;\n                    id: string;\n                    _sortIndex: number;\n                }>;\n\n                // 分离要移动的歌单和剩余的歌单\n                const selectedSheetIds = new Set(selectedSheets.map(s => `${s.platform}:${s.id}`));\n                const sheetsToMove = allSheets.filter(s => selectedSheetIds.has(`${s.platform}:${s.id}`));\n                const remainingSheets = allSheets.filter(s => !selectedSheetIds.has(`${s.platform}:${s.id}`));\n\n                if (!sheetsToMove.length) {\n                    return 0;\n                }\n\n                // 计算插入位置\n                let insertIndex = 0;\n                if (targetPlatform && targetId) {\n                    const targetIndex = remainingSheets.findIndex(s =>\n                        s.platform === targetPlatform && s.id === targetId,\n                    );\n                    insertIndex = targetIndex === -1 ? 0 :\n                        (position === \"after\" ? targetIndex + 1 : targetIndex);\n                } else {\n                    insertIndex = position === \"before\" ? 0 : remainingSheets.length;\n                }\n\n                // 构建新的排序数组并重新分配排序索引\n                const newOrderedSheets = [...remainingSheets];\n                newOrderedSheets.splice(insertIndex, 0, ...sheetsToMove);\n\n                const updateStmt = database.prepare(`\n                    UPDATE LocalMusicSheets SET _sortIndex = ? WHERE platform = ? AND id = ?\n                `);\n\n                let successCount = 0;\n                newOrderedSheets.forEach((sheet, index) => {\n                    const newSortIndex = this.SORT_BASE + (index * this.SORT_INCREMENT);\n                    try {\n                        const result = updateStmt.run(newSortIndex, sheet.platform, sheet.id);\n                        if (result.changes > 0 && selectedSheetIds.has(`${sheet.platform}:${sheet.id}`)) {\n                            successCount++;\n                        }\n                    } catch {\n                        // 静默处理单个歌单移动失败的情况\n                    }\n                });\n\n                return successCount;\n            });\n\n            return transaction();\n        } catch {\n            return 0;\n        }\n    }\n\n    /**\n     * 搜索歌单\n     * @param keyword 搜索关键词\n     * @param searchFields 搜索字段数组，默认搜索title和artist\n     * @returns 匹配的歌单数组\n     */\n    static searchMusicSheets(\n        keyword: string,\n        searchFields: string[] = [\"title\", \"artist\"],\n    ): IDataBaseModel.IMusicSheetModel[] {\n        try {\n            if (!keyword.trim()) {\n                return this.getAllMusicSheets();\n            }\n\n            const allowedFields = [\"title\", \"artist\", \"description\"];\n            const validFields = searchFields.filter(field => allowedFields.includes(field));\n\n            if (validFields.length === 0) {\n                validFields.push(\"title\");\n            }\n\n            const whereConditions = validFields.map(field => `${field} LIKE ?`).join(\" OR \");\n            const searchPattern = `%${keyword}%`;\n            const params = validFields.map(() => searchPattern);\n\n            const searchStmt = database.prepare(`\n                SELECT * FROM LocalMusicSheets \n                WHERE ${whereConditions}\n                ORDER BY _sortIndex ASC\n            `);\n\n            const results = searchStmt.all(...params) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                title: result.title,\n                artwork: result.artwork,\n                description: result.description,\n                worksNum: result.worksNum,\n                playCount: result.playCount,\n                createAt: result.createAt,\n                artist: result.artist,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n            }));\n        } catch {\n            return [];\n        }\n    }\n\n    /**\n     * 获取歌单中的所有歌曲\n     * @param platform 歌单平台\n     * @param id 歌单ID\n     * @param orderBy 排序字段，默认按_sortIndex排序\n     * @param order 排序方向\n     * @returns 歌曲数组\n     */\n    static getMusicItemsInSheet(\n        platform: string,\n        id: string,\n        orderBy: string = \"_sortIndex\",\n        order: \"ASC\" | \"DESC\" = \"ASC\",\n    ): IDataBaseModel.IMusicItemModel[] {\n        try {\n            const allowedFields = [\"_sortIndex\", \"title\", \"artist\", \"album\", \"_timestamp\"];\n            if (!allowedFields.includes(orderBy)) {\n                orderBy = \"_sortIndex\";\n            }\n\n            const selectStmt = database.prepare(`\n                SELECT * FROM LocalMusicItems \n                WHERE _musicSheetPlatform = ? AND _musicSheetId = ?\n                ORDER BY ${orderBy} ${order}\n            `);\n            const results = selectStmt.all(platform, id) as any[];\n\n            return results.map(result => ({\n                platform: result.platform,\n                id: result.id,\n                artist: result.artist,\n                title: result.title,\n                duration: result.duration,\n                album: result.album,\n                artwork: result.artwork,\n                _timestamp: result._timestamp,\n                _raw: result._raw,\n                _sortIndex: result._sortIndex,\n                _musicSheetId: result._musicSheetId,\n                _musicSheetPlatform: result._musicSheetPlatform,\n            }));\n        } catch {\n            return [];\n        }\n    }\n\n    /**\n     * 获取歌单中歌曲的数量\n     * @param platform 歌单平台\n     * @param id 歌单ID\n     * @returns 歌曲数量\n     */\n    static getMusicItemCountInSheet(platform: string, id: string): number {\n        try {\n            const countStmt = database.prepare(`\n                SELECT COUNT(*) as count FROM LocalMusicItems \n                WHERE _musicSheetPlatform = ? AND _musicSheetId = ?\n            `);\n            const result = countStmt.get(platform, id) as { count: number };\n            return result.count;\n        } catch {\n            return 0;\n        }\n    }\n\n    /**\n     * 检查歌单是否存在\n     * @param platform 平台\n     * @param id 歌单ID\n     * @returns 是否存在\n     */\n    static existsMusicSheet(platform: string, id: string): boolean {\n        try {\n            const countStmt = database.prepare(\"SELECT COUNT(*) as count FROM LocalMusicSheets WHERE platform = ? AND id = ?\");\n            const result = countStmt.get(platform, id) as { count: number };\n            return result.count > 0;\n        } catch {\n            return false;\n        }\n    }\n}\n\n\n// 导出数据库实例和类\nexport { database, LocalMusicSheetDB };\n"
  },
  {
    "path": "src/shared/database/renderer.ts",
    "content": ""
  },
  {
    "path": "src/shared/global-context/internal/common.ts",
    "content": "\n/**\n * Evt send by Renderer process\n */\nexport enum _IpcRendererEvt {\n    GET_GLOBAL_DATA = \"shared/global-data/get-global-data\",\n}\n"
  },
  {
    "path": "src/shared/global-context/main.ts",
    "content": "import { app, ipcMain } from \"electron\";\nimport { _IpcRendererEvt } from \"./internal/common\";\nimport path from \"path\";\n\ndeclare const WORKER_DOWNLOADER_WEBPACK_ENTRY: string;\ndeclare const LOCAL_FILE_WATCHER_WEBPACK_ENTRY: string;\ndeclare const DB_WEBPACK_ENTRY: string;\n\nexport function setupGlobalContext() {\n    ipcMain.on(_IpcRendererEvt.GET_GLOBAL_DATA, (evt) => {\n        evt.returnValue = {\n            appVersion: app.getVersion(),\n            workersPath: {\n                downloader: WORKER_DOWNLOADER_WEBPACK_ENTRY,\n                localFileWatcher: LOCAL_FILE_WATCHER_WEBPACK_ENTRY,\n                db: DB_WEBPACK_ENTRY,\n            },\n            appPath: {\n                downloads: app.getPath(\"downloads\"),\n                temp: app.getPath(\"temp\"),\n                userData: app.getPath(\"userData\"),\n                res: app.isPackaged\n                    ? path.resolve(process.resourcesPath, \"res\")\n                    : path.resolve(__dirname, \"../../res\"),\n            },\n            platform: process.platform,\n        };\n    });\n}\n"
  },
  {
    "path": "src/shared/global-context/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport { IGlobalContext } from \"./type\";\nimport { _IpcRendererEvt } from \"./internal/common\";\n\nlet globalContext: IGlobalContext;\n\nexport function getGlobalContext() {\n    if (!globalContext) {\n        globalContext = ipcRenderer.sendSync(_IpcRendererEvt.GET_GLOBAL_DATA);\n    }\n    return globalContext;\n}\n\nconst mod = {\n    getGlobalContext,\n};\n\ngetGlobalContext();\ncontextBridge.exposeInMainWorld(\"@shared/global-context\", mod);\n"
  },
  {
    "path": "src/shared/global-context/renderer.ts",
    "content": "import type { IGlobalContext } from \"./type\";\n\nconst mod = window[\"@shared/global-context\" as any] as any;\n\nexport const getGlobalContext: () => IGlobalContext = mod.getGlobalContext;\n"
  },
  {
    "path": "src/shared/global-context/type.d.ts",
    "content": "export interface IGlobalContext {\n  /** 版本号 */\n  appVersion: string;\n  workersPath: {\n    /** 下载器worker */\n    downloader: string;\n    /** 本地文件监听器worker */\n    localFileWatcher: string;\n    /** 用于备份文件的worker */\n    db: string;\n  };\n  appPath: {\n    userData: string;\n    temp: string;\n    downloads: string;\n    res: string;\n  };\n  platform: NodeJS.Platform;\n}\n"
  },
  {
    "path": "src/shared/i18n/main.ts",
    "content": "import { app, ipcMain } from \"electron\";\nimport path from \"path\";\nimport fs from \"fs/promises\";\nimport i18n from \"i18next\";\nimport logger from \"@shared/logger/main\";\n\nconst ns = \"translation\";\n\nconst resPath = app.isPackaged\n    ? path.resolve(process.resourcesPath, \"res\")\n    : path.resolve(__dirname, \"../../res\");\n\nexport const getResPath = (resourceName: string) => {\n    return path.resolve(resPath, resourceName);\n};\n\nlet allLangs: string[] = [];\n\nasync function readLangContent(\n    langCode: string,\n    enableRedirect = true,\n): Promise<object | null> {\n    const langPath = path.resolve(getResPath(`./lang/${langCode}.json`));\n    try {\n        const content = await fs.readFile(langPath, \"utf8\");\n        const jsonObj = JSON.parse(content);\n        if (jsonObj[\"$alias\"] && enableRedirect) {\n            return readLangContent(jsonObj[\"$alias\"], false);\n        }\n        return jsonObj;\n    } catch {\n        return null;\n    }\n}\n\ninterface ISetupI18nOptions {\n    getDefaultLang?: () => string | null;\n    onLanguageChanged?: (lang: string) => void;\n}\n\nexport async function setupI18n(options?: ISetupI18nOptions) {\n    const { getDefaultLang, onLanguageChanged } = options || {};\n\n    const basicDir = getResPath(\"./lang\");\n    try {\n        await i18n.init({\n            resources: {},\n        });\n\n        const dirContents = await fs.readdir(basicDir, {\n            withFileTypes: true,\n        });\n\n        allLangs = dirContents\n            .filter((it) => it.isFile() && it.name.endsWith(\".json\"))\n            .map((it) => it.name.slice(0, -5));\n\n        let defaultLang = getDefaultLang?.();\n        if (defaultLang && !allLangs.includes(defaultLang)) {\n            defaultLang = undefined;\n        }\n\n        if (!defaultLang) {\n            const appLocale = app.getLocale();\n            if (allLangs.includes(appLocale)) {\n                defaultLang = appLocale;\n            } else if (appLocale.includes(\"zh\") && allLangs.includes(\"zh-CN\")) {\n                defaultLang = \"zh-CN\";\n            } else if (allLangs.includes(\"en-US\")) {\n                defaultLang = \"en-US\";\n            } else {\n                defaultLang = allLangs[0];\n            }\n        }\n\n        const langContent = await readLangContent(defaultLang);\n        if (defaultLang && langContent) {\n            i18n.addResourceBundle(defaultLang, ns, langContent);\n            i18n.changeLanguage(defaultLang);\n        }\n\n        ipcMain.handle(\"shared/i18n/setup\", async () => {\n            const currentLang = i18n.language;\n            const langContent = await readLangContent(currentLang);\n            if (langContent) {\n                return {\n                    lang: currentLang,\n                    content: langContent,\n                    allLangs,\n                };\n            }\n            return null;\n        });\n\n        ipcMain.handle(\"shared/i18n/changeLang\", async (_, lang: string) => {\n            if (i18n.hasResourceBundle(lang, ns)) {\n                await i18n.changeLanguage(lang);\n                onLanguageChanged?.(lang);\n                return {\n                    lang,\n                    content: i18n.getResourceBundle(lang, ns),\n                };\n            } else {\n                const langContent = await readLangContent(lang);\n                if (langContent) {\n                    i18n.addResourceBundle(lang, ns, langContent);\n                    await i18n.changeLanguage(lang);\n                    onLanguageChanged?.(lang);\n                    return {\n                        lang,\n                        content: langContent,\n                    };\n                }\n            }\n\n            return null;\n        });\n    } catch (e){\n        logger.logError(\"I18N Setup Error\", e as Error);\n    }\n}\n\nexport const t = i18n.t.bind(i18n);\n\nexport default {\n    setup: setupI18n,\n    t: i18n.t,\n};\n"
  },
  {
    "path": "src/shared/i18n/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport type { IChangeLangData, ISetupData } from \"./type\";\n\nasync function setupLang() {\n    const data: ISetupData = await ipcRenderer.invoke(\"shared/i18n/setup\");\n    return data;\n}\n\nasync function changeLang(lang: string) {\n    const data: IChangeLangData = await ipcRenderer.invoke(\"shared/i18n/changeLang\", lang);\n    return data;\n}\n\nconst mod = {\n    setupLang,\n    changeLang,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/i18n\", mod);\n\n"
  },
  {
    "path": "src/shared/i18n/renderer.ts",
    "content": "import i18n from \"i18next\";\nimport Store from \"@/common/store\";\nimport { initReactI18next } from \"react-i18next\";\nimport { IMod } from \"./type\";\n\ni18n.use(initReactI18next);\n\nconst ns = \"translation\";\n\nconst langListStore = new Store<string[]>([]);\n\nconst mod = window[\"@shared/i18n\" as any] as unknown as IMod;\n\nexport async function setupI18n() {\n    const { allLangs = [], content, lang } = (await mod.setupLang()) || {};\n    langListStore.setValue(allLangs);\n    await i18n.init({\n        resources: {\n            [lang]: {\n                [ns]: content,\n            },\n        },\n        lng: lang,\n    });\n}\n\nexport async function changeLang(lang: string): Promise<boolean> {\n    const langData = await mod.changeLang(lang);\n    if (!langData) {\n        return false;\n    }\n    if (i18n.hasResourceBundle(lang, ns)) {\n        await i18n.changeLanguage(lang);\n    } else {\n        i18n.addResourceBundle(lang, ns, langData.content);\n        await i18n.changeLanguage(lang);\n    }\n    return true;\n}\n\nexport const useLangList = langListStore.useValue;\n\nexport const getLangList = langListStore.getValue;\n\nexport const isCN = () => i18n.language.includes(\"zh-CN\");\n\nexport { i18n };\n\n\nexport default {\n    setupI18n,\n    changeLang,\n    useLangList,\n    getLangList,\n    i18n,\n};\n\n"
  },
  {
    "path": "src/shared/i18n/type.d.ts",
    "content": "export interface ISetupData {\n    allLangs: string[];\n    lang: string;\n    content: any\n}\n\nexport interface IChangeLangData {\n    lang: string;\n    content: any;\n}\n\nexport interface IMod {\n    setupLang: () => Promise<ISetupData | null>;\n    changeLang: (lang: string) => Promise<IChangeLangData | null>;\n}"
  },
  {
    "path": "src/shared/logger/main.ts",
    "content": "import log from \"electron-log/main\";\nimport { safeStringify } from \"@/common/safe-serialization\";\n\n\nfunction logError(msg: string, error: Error, extra?: any) {\n    log.error(msg, error?.name, error?.message, error?.stack, safeStringify(extra));\n}\n\nfunction logInfo(msg: string, extra?: any) {\n    log.info(msg, safeStringify(extra));\n}\n\n\nlet firstPerfLogTime = 0;\nfunction logPerf(msg: string) {\n    const timestamp = Date.now();\n    if (!firstPerfLogTime) {\n        firstPerfLogTime = timestamp;\n    }\n    log.info(\"[Perf Main]: \" + msg + \" [Offset]: \" + (timestamp - firstPerfLogTime) + \"ms\");\n}\n\nconst logger = {\n    logInfo,\n    logError,\n    logPerf,\n};\n\nexport default logger;\n"
  },
  {
    "path": "src/shared/logger/renderer.ts",
    "content": "import log from \"electron-log/renderer\";\nimport { safeStringify } from \"@/common/safe-serialization\";\n\n\n\nfunction logError(msg: string, error: Error, extra?: any) {\n    log.error(msg, error?.name, error?.message, error?.stack, safeStringify(extra));\n}\n\nfunction logInfo(msg: string, extra?: any) {\n    log.info(msg, safeStringify(extra));\n}\n\nlet firstPerfLogTime = 0;\nfunction logPerf(msg: string) {\n    const timestamp = Date.now();\n    if (!firstPerfLogTime) {\n        firstPerfLogTime = timestamp;\n    }\n    log.info(\"[Perf Renderer]: \" + msg + \" [Offset]: \" + (timestamp - firstPerfLogTime) + \"ms\");\n}\n\n\nconst logger = {\n    logInfo,\n    logError,\n    logPerf,\n};\n\nexport default logger;\n"
  },
  {
    "path": "src/shared/message-bus/main.ts",
    "content": "import { IAppState, ICommand } from \"@shared/message-bus/type\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport { BrowserWindow, ipcMain, MessageChannelMain } from \"electron\";\nimport { PlayerState, RepeatMode } from \"@/common/constant\";\nimport EventEmitter from \"eventemitter3\";\n\n/**\n * 消息总线\n * 包括应用状态、指令的同步\n */\nclass MessageBus {\n\n    private windowManager: IWindowManager;\n    private extensionWindowIds = new Set<number>();\n    private appState: IAppState = {\n        musicItem: null,\n        playerState: PlayerState.None,\n        repeatMode: RepeatMode.Loop,\n        lyricText: null,\n    };\n    private ee = new EventEmitter<{\n        stateChanged: [IAppState, IAppState]\n    }>();\n\n    public setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n\n        // 配置现有窗口\n        const extensionWindows = this.windowManager.getExtensionWindows();\n        for (const bWindow of extensionWindows) {\n            this.createPortForExtensionWindow(bWindow);\n        }\n        windowManager.on(\"WindowCreated\", (data) => {\n            if (data.windowName !== \"main\") {\n                this.createPortForExtensionWindow(data.browserWindow);\n            }\n        });\n\n        ipcMain.on(\"@shared/message-bus/sync-app-state\", (_, data: IAppState) => {\n            this.appState = {\n                ...this.appState,\n                ...data,\n            };\n            this.ee.emit(\"stateChanged\", this.appState, data);\n        });\n    }\n\n    public onAppStateChange(cb: (state: IAppState, changedAppState: IAppState) => void) {\n        this.ee.on(\"stateChanged\", cb);\n    }\n\n    /**\n     * 发送指令\n     * @param command 指令\n     * @param data 数据\n     */\n    public sendCommand<T extends keyof ICommand>(command: T, data?: ICommand[T]) {\n        const mainWindow = this.windowManager.mainWindow;\n        if (mainWindow) {\n            mainWindow.webContents.send(\"@shared/message-bus/message\", {\n                type: \"command\",\n                payload: {\n                    command,\n                    data,\n                },\n                timestamp: Date.now(),\n            });\n        }\n    }\n\n    public getAppState() {\n        return this.appState;\n    }\n\n    // 创建通信端口\n    private createPortForExtensionWindow(bWindow: BrowserWindow) {\n        const mainWindow = this.windowManager.mainWindow;\n        if (!mainWindow || bWindow === mainWindow) {\n            return;\n        }\n        const { port1, port2 } = new MessageChannelMain();\n        const extWindowId = bWindow.id;\n        this.extensionWindowIds.add(extWindowId);\n\n        // 通知主窗口更新\n        mainWindow.webContents.postMessage(\"port\", {\n            payload: extWindowId,\n            type: \"mount\",\n            timestamp: Date.now(),\n        }, [port1]);\n\n        bWindow.webContents.postMessage(\"port\", null, [port2]);\n        bWindow.on(\"close\", () => {\n            mainWindow.webContents.postMessage(\"port\", {\n                payload: extWindowId,\n                type: \"unmount\",\n                timestamp: Date.now(),\n            });\n            this.extensionWindowIds.delete(extWindowId);\n        });\n\n    }\n}\n\n\nconst messageBus = new MessageBus();\nexport default messageBus;\n"
  },
  {
    "path": "src/shared/message-bus/preload/extension.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport { IAppState, ICommand, IPortMessage } from \"@shared/message-bus/type\";\nimport EventEmitter from \"eventemitter3\";\n\nlet extPort: MessagePort = null;\nlet appState: IAppState = {};\nconst ee = new EventEmitter<{\n    stateChanged: [IAppState, IAppState];\n}>();\n\n// 初始化\nlet connected = false;\nlet pingTimer: NodeJS.Timeout | null = null;\n// 缓存未建立连接时的消息\nconst cachedMessages: IPortMessage[] = [];\n\nipcRenderer.on(\"port\", (e) => {\n    extPort = e.ports[0];\n    pingTimer = setInterval(() => {\n        console.log(\"ping\");\n        // 向主进程发送 ping\n        extPort.postMessage({\n            type: \"ping\",\n            timestamp: Date.now(),\n        });\n    }, 300);\n    extPort.onmessage = (evt) => {\n        const data = evt.data;\n\n        if (data.type === \"syncAppState\") {\n            appState = {\n                ...appState,\n                ...(data.payload || {}),\n            };\n            ee.emit(\"stateChanged\", appState, data.payload || {});\n        } else if (data.type === \"ping\") {\n            connected = true;\n            clearInterval(pingTimer);\n            pingTimer = null;\n            if (cachedMessages.length) {\n                cachedMessages.forEach((message) => {\n                    extPort.postMessage(message);\n                });\n                cachedMessages.length = 0;\n            }\n        }\n    };\n});\n\nfunction sendCommand<T extends keyof ICommand>(command: T, data?: ICommand[T]) {\n    const message: IPortMessage = {\n        type: \"command\",\n        payload: {\n            command,\n            data,\n        },\n        timestamp: Date.now(),\n    };\n\n    if (!extPort || !connected) {\n        cachedMessages.push(message);\n        return;\n    }\n    extPort.postMessage(message);\n}\n\nfunction subscribeAppState(keys: (keyof IAppState)[]) {\n    const message: IPortMessage = {\n        type: \"subscribeAppState\",\n        payload: keys,\n        timestamp: Date.now(),\n    };\n\n    if (!extPort || !connected) {\n        cachedMessages.push(message);\n        return;\n    }\n    extPort.postMessage(message);\n}\n\nfunction getAppState() {\n    return appState;\n}\n\nfunction onStateChange(\n    cb: (appState: IAppState, changedAppState: IAppState) => void,\n) {\n    ee.on(\"stateChanged\", cb);\n}\n\nfunction offStateChange(\n    cb: (appState: IAppState, changedAppState: IAppState) => void,\n) {\n    ee.off(\"stateChanged\", cb);\n}\n\nconst mod = {\n    getAppState,\n    subscribeAppState,\n    sendCommand,\n    onStateChange,\n    offStateChange,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/message-bus/extension\", mod);\n"
  },
  {
    "path": "src/shared/message-bus/preload/main.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport EventEmitter from \"eventemitter3\";\nimport { IAppState, ICommand, IPortMessage } from \"@shared/message-bus/type\";\nimport { getGlobalContext } from \"@shared/global-context/preload\";\n\nconst extPorts = new Map<number, MessagePort>();\nconst subscribedAppStates = new Map<string | number, Array<keyof IAppState>>();\n\nconst mainProcessSubscribedKeys: Array<keyof IAppState> = [\n    \"lyricText\",\n    \"playerState\",\n    \"repeatMode\",\n    \"musicItem\",\n];\nif (getGlobalContext().platform === \"darwin\") {\n    mainProcessSubscribedKeys.push(\"lyricText\");\n}\nsubscribedAppStates.set(\"main\", mainProcessSubscribedKeys);\n\nconst ee = new EventEmitter();\n\n//@ts-ignore\nwindow.__extPorts = extPorts;\n\n// 主窗口的端口信息 (和拓展端口通信)\nipcRenderer.on(\"port\", (e, message) => {\n    // 接收到端口，使其全局可用\n    if (message.type === \"mount\") {\n        const expPort = e.ports[0];\n        extPorts.set(message.payload, expPort);\n        expPort.onmessage = (evt) => {\n            const data = evt.data;\n            handleMessage(data, message.payload);\n        };\n    } else if (message.type === \"unmount\") {\n        const closeId = message.payload;\n        const expPort = extPorts.get(closeId);\n        if (expPort) {\n            expPort.close();\n            extPorts.delete(closeId);\n            subscribedAppStates.delete(closeId);\n        }\n    } else {\n    // 其他类型作为主进程发来的普通消息处理\n        handleMessage(message, null);\n    }\n});\n\nipcRenderer.on(\"@shared/message-bus/message\", (_evt, message) => {\n    handleMessage(message, null);\n});\n\nfunction handleMessage(data: IPortMessage, from: number | null) {\n    const { type, payload, timestamp } = data;\n    if (type === \"mount\" || type === \"unmount\") {\n    // those are not real message\n        return;\n    }\n\n    if (type === \"ping\") {\n    // 渲染进程发来的建连消息\n        const expPort = extPorts.get(from);\n        // 返回一个相同的ping\n        if (expPort) {\n            expPort.postMessage({\n                type: \"ping\",\n                timestamp: Date.now(),\n            });\n        }\n    } else if (type === \"subscribeAppState\" && from !== null) {\n    // @ts-ignore\n        subscribedAppStates.set(from, payload);\n    } else if (type === \"command\") {\n        ee.emit(\"command\", payload, from);\n    }\n}\n\nfunction onCommand<K extends keyof ICommand>(\n    command: K,\n    cb: (data: ICommand[K], from: \"main\" | number) => void,\n) {\n    ee.on(\"command\", (payload, from) => {\n        if (payload.command === command) {\n            cb?.(payload.data, from);\n        }\n    });\n}\n\nfunction sendCommand<K extends keyof ICommand>(command: K, data: ICommand[K]) {\n    ee.emit(\n        \"command\",\n        {\n            command: command,\n            data: data,\n            timestamp: Date.now(),\n        },\n        -1,\n    );\n}\n\nfunction syncAppState(appState: IAppState, to?: \"main\" | number) {\n    if (to !== undefined) {\n        syncAppStateTo(appState, to);\n        return;\n    }\n    // 同步全部\n    syncAppStateTo(appState, \"main\");\n\n    for (const key of extPorts.keys()) {\n        syncAppStateTo(appState, key);\n    }\n}\n\nfunction syncAppStateTo(appState: IAppState, processId: \"main\" | number) {\n    const data: IAppState = {};\n    if (processId === \"main\") {\n        const mainSubscribedKeys = subscribedAppStates.get(processId);\n        let cnt = 0;\n        mainSubscribedKeys.forEach((key) => {\n            if (appState[key] !== undefined) {\n                // @ts-ignore\n                data[key] = appState[key];\n                ++cnt;\n            }\n        });\n        if (cnt) {\n            ipcRenderer.send(\"@shared/message-bus/sync-app-state\", data);\n        }\n        return;\n    }\n\n    const expPort = extPorts.get(processId);\n    const subscribedKeys = subscribedAppStates.get(processId);\n    if (subscribedKeys && expPort) {\n        const data: IAppState = {};\n        let cnt = 0;\n        subscribedKeys.forEach((key) => {\n            if (appState[key] !== undefined) {\n                // @ts-ignore\n                data[key] = appState[key];\n                ++cnt;\n            }\n        });\n        if (cnt) {\n            expPort.postMessage({\n                type: \"syncAppState\",\n                payload: data,\n                timestamp: Date.now(),\n            });\n        }\n    }\n}\n\nconst mod = {\n    syncAppState,\n    onCommand,\n    sendCommand,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/message-bus/main\", mod);\n"
  },
  {
    "path": "src/shared/message-bus/renderer/extension.ts",
    "content": "import { IAppState, ICommand } from \"@shared/message-bus/type\";\nimport { useEffect, useState } from \"react\";\n\ninterface IMod {\n    sendCommand: <K extends keyof ICommand>(\n        command: K,\n        data?: ICommand[K]\n    ) => void;\n    getAppState: () => IAppState;\n    subscribeAppState: (keys: (keyof IAppState)[]) => void;\n    onStateChange: (\n        cb: (appState: IAppState, changedAppState: IAppState) => void\n    ) => void;\n    offStateChange: (\n        cb: (appState: IAppState, changedAppState: IAppState) => void\n    ) => void;\n}\n\nconst mod = window[\"@shared/message-bus/extension\" as any] as unknown as IMod;\n\nexport function useAppState() {\n    const [appState, setAppState] = useState(mod.getAppState);\n\n    useEffect(() => {\n        mod.onStateChange(setAppState);\n        setAppState(mod.getAppState);\n        return () => {\n            mod.offStateChange(setAppState);\n        };\n    }, []);\n\n    return appState;\n}\n\nexport function useAppStatePartial<K extends keyof IAppState>(key: K) {\n    const [appState, setAppState] = useState<IAppState[K]>(\n        mod.getAppState()?.[key],\n    );\n    useEffect(() => {\n        const cb = (appState: IAppState, changedAppState: IAppState) => {\n            if (key in changedAppState) {\n                setAppState(mod.getAppState()[key]);\n            }\n        };\n\n        mod.onStateChange(cb);\n        setAppState(mod.getAppState()?.[key]);\n        return () => {\n            mod.offStateChange(cb);\n        };\n    }, []);\n\n    return appState;\n}\n\nconst messageBus = {\n    sendCommand: mod.sendCommand,\n    subscribeAppState: mod.subscribeAppState,\n    onStateChange: mod.onStateChange,\n    offStateChange: mod.offStateChange,\n};\n\nexport default messageBus;\n"
  },
  {
    "path": "src/shared/message-bus/renderer/main.ts",
    "content": "import { IAppState, ICommand } from \"@shared/message-bus/type\";\n\ninterface IMod {\n    syncAppState: (appState: IAppState, to?: \"main\" | number) => void;\n    onCommand: <K extends keyof ICommand>(command: K, cb: (data: ICommand[K], from: \"main\" | number) => void) => void;\n    sendCommand: <K extends keyof ICommand>(command: K, data?: ICommand[K]) => void;\n}\n\nconst messageBus = window[\"@shared/message-bus/main\" as any] as unknown as IMod;\n\n\nexport default messageBus;\n"
  },
  {
    "path": "src/shared/message-bus/type.d.ts",
    "content": "import { PlayerState, RepeatMode } from \"@/common/constant\";\nimport type { IParsedLrcItem } from \"@renderer/utils/lyric-parser\";\n\nexport interface IAppState {\n  musicItem?: IMusic.IMusicItem | null;\n  playerState?: PlayerState;\n  repeatMode?: RepeatMode;\n  lyricText?: string | null;\n  parsedLrc?: IParsedLrcItem | null;\n  fullLyric?: IParsedLrcItem[] | null;\n  progress?: number;\n  duration?: number;\n}\n\nexport interface ICommand {\n  /** 切换播放器状态 */\n  TogglePlayerState: void;\n  /** 切换上一首歌 */\n  SkipToPrevious: void;\n  /** 切换下一首歌 */\n  SkipToNext: void;\n  /** 设置循环模式 */\n  SetRepeatMode: RepeatMode;\n  /** 播放音乐 */\n  PlayMusic: IMusic.IMusicItem;\n  /** 跳转路由 */\n  Navigate: string;\n  /** 声音调大 */\n  VolumeUp: number;\n  /** 声音调小 */\n  VolumeDown: number;\n  /** 切换喜爱状态 */\n  ToggleFavorite: IMusic.IMusicItem | null;\n  /** 切换桌面歌词状态 */\n  ToggleDesktopLyric: void;\n  /** 同步音乐状态 */\n  SyncAppState: void;\n  /** 打开音乐详情页面 */\n  OpenMusicDetailPage: void;\n  /** 切换主窗口显示 */\n  ToggleMainWindowVisible: void;\n}\n\n// 内部使用的消息\n// 其他窗口向主窗口发送的消息\nexport interface IPortMessagePayload<\n  CommandKey extends keyof ICommand = keyof ICommand,\n  StateKey extends keyof IAppState = keyof IAppState\n> {\n  mount: number;\n  unmount: number;\n  command: {\n    command: CommandKey;\n    data: ICommand[CommandKey];\n  };\n  subscribeAppState: StateKey[];\n  ping: undefined;\n}\n\nexport interface IPortMessage<\n  T extends keyof IPortMessagePayload = keyof IPortMessagePayload\n> {\n  type: T;\n  payload: IPortMessagePayload[T];\n  timestamp: number;\n}\n"
  },
  {
    "path": "src/shared/plugin-manager/main/index.ts",
    "content": "import { app, ipcMain } from \"electron\";\nimport fs from \"fs/promises\";\nimport path from \"path\";\nimport { Plugin } from \"./plugin\";\nimport { rimraf } from \"rimraf\";\nimport _axios from \"axios\";\nimport https from \"https\";\nimport voidCallback from \"@/common/void-callback\";\nimport { localPluginHash, localPluginName } from \"@/common/constant\";\nimport localPlugin from \"./internal-plugins/local-plugin\";\nimport { addRandomHash } from \"@/common/normalize-util\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport AppConfig from \"@shared/app-config/main\";\nimport { compare } from \"compare-versions\";\nimport { nanoid } from \"nanoid\";\nimport logger from \"@shared/logger/main\";\n\nconst axios = _axios.create({\n    httpsAgent: new https.Agent({\n        rejectUnauthorized: false,\n    }),\n});\n\ninterface ICallPluginMethodParams<\n    T extends keyof IPlugin.IPluginInstanceMethods,\n> {\n    hash: string;\n    platform: string;\n    method: T;\n    args: Parameters<IPlugin.IPluginInstanceMethods[T]>;\n}\n\n\nclass PluginManager {\n    private clonedPlugins: IPlugin.IPluginDelegate[] = [];\n\n    private inited = false;\n\n    private _plugins: Plugin[] = [];\n    public get plugins() {\n        return this._plugins;\n    }\n\n    public set plugins(newPlugins: Plugin[]) {\n        this._plugins = newPlugins;\n        this.clonedPlugins = newPlugins.map((p) => {\n            const sPlugin: IPlugin.IPluginDelegate = {} as any;\n            sPlugin.supportedMethod = [];\n            for (const k in p.instance) {\n                // @ts-ignore\n                if (typeof p.instance[k] === \"function\") {\n                    sPlugin.supportedMethod.push(k);\n                } else {\n                    // @ts-ignore\n                    sPlugin[k] = p.instance[k];\n                }\n            }\n            sPlugin.hash = p.hash;\n            sPlugin.path = p.path;\n            return JSON.parse(JSON.stringify(sPlugin));\n        });\n    }\n\n    private windowManager: IWindowManager;\n\n    // 插件存储路径\n    private _pluginBasePath: string;\n\n    private get pluginBasePath() {\n        if (this._pluginBasePath) {\n            return this._pluginBasePath;\n        }\n        this._pluginBasePath = path.resolve(\n            app.getPath(\"userData\"),\n            \"./musicfree-plugins\",\n        );\n        return this._pluginBasePath;\n    }\n\n    public async setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n        // 1. setup events\n        ipcMain.handle(\"@shared/plugin-manager/call-plugin-method\", (_evt, data) => {\n            return this.callPluginMethod(data);\n        });\n\n        ipcMain.handle(\"@shared/plugin-manager/get-all-plugins\", () => this.clonedPlugins);\n\n        ipcMain.handle(\"@shared/plugin-manager/load-all-plugins\", async () => {\n            if (!this.inited) {\n                await this.loadAllPlugins();\n            } else {\n                this.syncPlugins();\n            }\n            return this.clonedPlugins;\n        });\n\n        ipcMain.handle(\"@shared/plugin-manager/uninstall-plugin\", async (_, hash) => {\n            await this.uninstallPlugin(hash);\n            this.syncPlugins();\n        });\n\n        ipcMain.on(\"@shared/plugin-manager/update-all-plugins\", this.updateAllPlugins);\n\n        ipcMain.handle(\"@shared/plugin-manager/install-plugin-remote\", async (_, urlLike) => {\n            return await this.installPluginFromRemoteUrl(urlLike);\n        });\n\n        ipcMain.handle(\"@shared/plugin-manager/install-plugin-local\", async (_, urlLike) => {\n            return await this.installPluginFromLocalFile(urlLike);\n        });\n\n        // 2. check if folder exists\n        let folderExists = true;\n        try {\n            const res = await fs.stat(this.pluginBasePath);\n            if (!res.isDirectory()) {\n                await rimraf(this.pluginBasePath);\n                folderExists = false;\n            }\n        } catch {\n            folderExists = false;\n        }\n        if (!folderExists) {\n            await fs.mkdir(this.pluginBasePath, {\n                recursive: true,\n            }).catch(voidCallback);\n        }\n\n        // 3. load all plugins\n        await this.loadAllPlugins();\n        this.inited = true;\n    }\n\n    // 调用某个插件的方法\n    private callPluginMethod({\n        hash,\n        platform,\n        method,\n        args,\n    }: ICallPluginMethodParams<keyof IPlugin.IPluginInstanceMethods>,\n    ) {\n        let plugin: Plugin;\n        if (hash === localPluginHash || platform === localPluginName) {\n            plugin = localPlugin;\n        } else if (hash) {\n            plugin = this.plugins.find((item) => item.hash === hash);\n        } else if (platform) {\n            plugin = this.plugins.find((item) => item.name === platform);\n        }\n        if (!plugin) {\n            return null;\n        }\n        return plugin.methods[method]?.apply?.({ plugin }, args);\n    }\n\n    private syncPlugins() {\n        const mainWindow = this.windowManager.mainWindow;\n        if (mainWindow) {\n            mainWindow.webContents.send(\"@/shared/plugin-manager/sync-plugins\", this.clonedPlugins);\n        }\n    }\n\n\n    /********************** 安装插件 *******************/\n    private async installPluginFromRawCodeImpl(funcCode: string) {\n        const plugins = this.plugins;\n        const plugin = new Plugin(funcCode, \"\");\n        const pluginIndex = plugins.findIndex((p) => p.hash === plugin.hash);\n        if (pluginIndex !== -1) {\n            // 静默忽略\n            return;\n        }\n        const oldVersionPlugin = plugins.find((p) => p.name === plugin.name);\n        if (\n            oldVersionPlugin &&\n            !AppConfig.getConfig(\"plugin.notCheckPluginVersion\")\n        ) {\n            if (\n                compare(\n                    oldVersionPlugin.instance.version ?? \"\",\n                    plugin.instance.version ?? \"\",\n                    \">\",\n                )\n            ) {\n                throw new Error(\"已安装更新版本的插件\");\n            }\n        }\n\n        if (plugin.hash !== \"\") {\n            const fn = nanoid();\n            const _pluginPath = path.resolve(this.pluginBasePath, `${fn}.js`);\n            await fs.writeFile(_pluginPath, funcCode, \"utf8\");\n            plugin.path = _pluginPath;\n            let newPlugins = plugins.concat(plugin);\n            if (oldVersionPlugin) {\n                newPlugins = newPlugins.filter((_) => _.hash !== oldVersionPlugin.hash);\n                try {\n                    await rimraf(oldVersionPlugin.path);\n                } catch {\n                    // pass\n                }\n            }\n            this.plugins = newPlugins;\n            return;\n        }\n        throw new Error(\"插件无法解析!\");\n    }\n\n    private async installPluginFromUrlImpl(urlLike: string) {\n        const funcCode = (await axios.get(urlLike)).data;\n        if (funcCode) {\n            await this.installPluginFromRawCodeImpl(funcCode);\n        }\n    }\n\n    // 加载所有插件\n    public async loadAllPlugins() {\n        const rawPluginNames = await fs.readdir(this.pluginBasePath);\n        const pluginHashSet = new Set<string>();\n        const plugins: Plugin[] = [];\n        for (let i = 0; i < rawPluginNames.length; ++i) {\n            try {\n                const pluginPath = path.resolve(this.pluginBasePath, rawPluginNames[i]);\n                const fileStat = await fs.stat(pluginPath);\n                if (fileStat.isFile() && path.extname(pluginPath) === \".js\") {\n                    const funcCode = await fs.readFile(pluginPath, \"utf-8\");\n                    const plugin = new Plugin(funcCode, pluginPath);\n                    if (pluginHashSet.has(plugin.hash)) {\n                        continue;\n                    }\n                    if (plugin.hash !== \"\") {\n                        pluginHashSet.add(plugin.hash);\n                        plugins.push(plugin);\n                    }\n                }\n            } catch (e) {\n                logger.logError(\"插件加载失败\", e);\n            }\n        }\n        this.plugins = plugins;\n        this.syncPlugins();\n    }\n\n    // 从本地文件安装插件\n    public async installPluginFromLocalFile(urlLike: string) {\n        try {\n            const url = urlLike.trim();\n            if (url.endsWith(\".js\")) {\n                const rawCode = await fs.readFile(url, \"utf8\");\n                await this.installPluginFromRawCodeImpl(rawCode);\n            } else if (url.endsWith(\".json\")) {\n                const jsonFile = JSON.parse(await fs.readFile(url, \"utf8\"));\n\n                for (const cfg of jsonFile?.plugins ?? []) {\n                    await this.installPluginFromUrlImpl(addRandomHash(cfg.url));\n                }\n            }\n        } finally {\n            this.syncPlugins();\n        }\n    }\n\n    // 从远程url安装插件\n    public async installPluginFromRemoteUrl(urlLike: string) {\n        try {\n            const url = urlLike.trim();\n            if (url.endsWith(\".js\")) {\n                await this.installPluginFromUrlImpl(addRandomHash(url));\n            } else if (url.endsWith(\".json\")) {\n                const jsonFile = (await axios.get(addRandomHash(url))).data;\n\n                for (const cfg of jsonFile?.plugins ?? []) {\n                    await this.installPluginFromUrlImpl(addRandomHash(cfg.url));\n                }\n            }\n        } finally {\n            this.syncPlugins();\n        }\n    }\n\n    // 更新所有插件\n    public async updateAllPlugins() {\n        return Promise.allSettled(\n            this.plugins.map((plg) =>\n                plg.instance.srcUrl ? this.installPluginFromRemoteUrl(plg.instance.srcUrl) : null,\n            ),\n        );\n    }\n\n    // 卸载插件\n    public async uninstallPlugin(hash: string) {\n        const targetIndex = this.plugins.findIndex((_) => _.hash === hash);\n        if (targetIndex !== -1) {\n            try {\n                await rimraf(this.plugins[targetIndex].path);\n                this.plugins = this.plugins.filter((_) => _.hash !== hash);\n            } catch {\n                // pass\n            }\n        }\n    }\n}\n\n\nexport default new PluginManager();\n"
  },
  {
    "path": "src/shared/plugin-manager/main/internal-plugins/local-plugin.ts",
    "content": "import { localPluginHash, localPluginName } from \"@/common/constant\";\nimport { Plugin } from \"../plugin\";\nimport { addFileScheme, parseLocalMusicItem, parseLocalMusicItemFolder } from \"@/common/file-util\";\n\n\nfunction localPluginDefine(): IPlugin.IPluginInstance {\n    return {\n        platform: localPluginName,\n        _path: \"\",\n        async getMediaSource(musicItem) {\n            return {\n                url: addFileScheme(musicItem.url),\n            };\n        },\n        async getLyric(musicItem) {\n            return {\n                rawLrc: musicItem.rawLrc,\n            };\n        },\n        async importMusicItem(filePath) {\n            return parseLocalMusicItem(filePath);\n        },\n        async importMusicSheet(folderPath) {\n            return parseLocalMusicItemFolder(folderPath);\n        },\n    };\n}\n\nconst localPlugin = new Plugin(localPluginDefine, \"\");\nlocalPlugin.hash = localPluginHash;\nexport default localPlugin;\n"
  },
  {
    "path": "src/shared/plugin-manager/main/plugin-methods.ts",
    "content": "import { getInternalData, resetMediaItem } from \"@/common/media-util\";\nimport type { Plugin } from \"./plugin\";\nimport { localFilePathSymbol } from \"@/common/constant\";\nimport fs from \"fs/promises\";\nimport { delay } from \"@/common/time-util\";\nimport axios from \"axios\";\nimport { addFileScheme, safeStat } from \"@/common/file-util\";\nimport path from \"path\";\n\nexport default class PluginMethods implements IPlugin.IPluginInstanceMethods {\n    private plugin;\n    constructor(plugin: Plugin) {\n        this.plugin = plugin;\n    }\n    /** 搜索 */\n    async search<T extends IMedia.SupportMediaType>(\n        query: string,\n        page: number,\n        type: T,\n    ): Promise<IPlugin.ISearchResult<T>> {\n        if (!this.plugin.instance.search) {\n            return {\n                isEnd: true,\n                data: [],\n            };\n        }\n\n        const result = await this.plugin.instance.search(query, page, type);\n        console.log(result, this.plugin.instance.search, query, page, type);\n        if (Array.isArray(result.data)) {\n            result.data.forEach((_) => {\n                resetMediaItem(_, this.plugin.name);\n            });\n            return {\n                isEnd: result.isEnd ?? true,\n                data: result.data,\n            };\n        }\n        return {\n            isEnd: true,\n            data: [],\n        };\n    }\n\n    /** 获取真实源 */\n    async getMediaSource(\n        musicItem: IMedia.IMediaBase,\n        quality: IMusic.IQualityKey = \"standard\",\n        retryCount = 1,\n        notUpdateCache = false,\n    ): Promise<IPlugin.IMediaSourceResult | null> {\n    // TODO 2. url 缓存策略，先略过\n\n        // 3 插件解析\n        if (!this.plugin.instance.getMediaSource) {\n            return { url: musicItem?.qualities?.[quality]?.url ?? musicItem.url };\n        }\n        try {\n            const { url, headers } = (await this.plugin.instance.getMediaSource(\n                musicItem,\n                quality,\n            )) ?? { url: musicItem?.qualities?.[quality]?.url };\n            if (!url) {\n                throw new Error(\"NOT RETRY\");\n            }\n            const result = {\n                url,\n                headers,\n                userAgent: headers?.[\"user-agent\"],\n            } as IPlugin.IMediaSourceResult;\n\n            //   if (pluginCacheControl !== CacheControl.NoStore && !notUpdateCache) {\n            //     Cache.update(musicItem, [\n            //       [\"headers\", result.headers],\n            //       [\"userAgent\", result.userAgent],\n            //       [`qualities.${quality}.url`, url],\n            //     ]);\n            //   }\n\n            return result;\n        } catch (e: any) {\n            console.log(e);\n            if (retryCount > 0 && e?.message !== \"NOT RETRY\") {\n                await delay(150);\n                return this.plugin.methods.getMediaSource(\n                    musicItem,\n                    quality,\n                    --retryCount,\n                );\n            }\n            // devLog('error', '获取真实源失败', e, e?.message);\n            return null;\n        }\n    }\n\n    /** 获取音乐详情 */\n    async getMusicInfo(\n        musicItem: IMedia.IMediaBase,\n    ): Promise<Partial<IMusic.IMusicItem> | null> {\n        if (!this.plugin.instance.getMusicInfo) {\n            return null;\n        }\n        try {\n            return (\n                this.plugin.instance.getMusicInfo(\n                    resetMediaItem(musicItem, undefined, true),\n                ) ?? null\n            );\n        } catch (e: any) {\n            // devLog('error', '获取音乐详情失败', e, e?.message);\n            return null;\n        }\n    }\n\n    /** 获取歌词 */\n    async getLyric(\n        musicItem: IMusic.IMusicItem,\n    ): Promise<ILyric.ILyricSource | null> {\n        let rawLrc = musicItem.rawLrc;\n        let lrcUrl = musicItem.lrc;\n        let translation: string;\n        // 如果存在文本\n        if (rawLrc) {\n            return {\n                rawLrc,\n                lrc: lrcUrl,\n            };\n        }\n        // 2. 读取路径下的同名lrc文件\n        const localPath =\n      getInternalData<IMusic.IMusicItemInternalData>(musicItem, \"downloadData\")\n          ?.path || musicItem.$$localPath;\n        if (localPath) {\n            const fileName = path.parse(localPath).name;\n            const lrcPathWithoutExt = path.resolve(localPath, `../${fileName}`);\n            const lrcTranslationPathWithoutExt = path.resolve(\n                localPath,\n                `../${fileName}-tr`,\n            );\n            const exts = [\".lrc\", \".LRC\", \".txt\"];\n\n            for (const ext of exts) {\n                const lrcFilePath = lrcPathWithoutExt + ext;\n                if ((await safeStat(lrcFilePath))?.isFile()) {\n                    rawLrc = await fs.readFile(lrcFilePath, \"utf8\");\n\n                    if ((await safeStat(lrcTranslationPathWithoutExt + ext))?.isFile()) {\n                        translation = await fs.readFile(\n                            lrcTranslationPathWithoutExt + ext,\n                            \"utf8\",\n                        );\n                    }\n\n                    if (rawLrc) {\n                        return {\n                            rawLrc,\n                            translation,\n                            lrc: lrcUrl,\n                        };\n                    }\n                }\n            }\n        }\n        // // 2.本地缓存\n        // const localLrc =\n        //     meta?.[internalSerializeKey]?.local?.localLrc ||\n        //     cache?.[internalSerializeKey]?.local?.localLrc;\n        // if (localLrc && (await exists(localLrc))) {\n        //     rawLrc = await readFile(localLrc, 'utf8');\n        //     return {\n        //         rawLrc,\n        //         lrc: lrcUrl,\n        //     };\n        // }\n        // 3.优先使用url\n\n        try {\n            const lrcSource = await this.plugin.instance?.getLyric?.(\n                resetMediaItem(musicItem, undefined, true),\n            );\n\n            rawLrc = lrcSource?.rawLrc;\n            lrcUrl = lrcSource?.lrc || lrcUrl;\n            translation = lrcSource?.translation;\n\n            if (rawLrc || translation) {\n                if (!rawLrc) {\n                    rawLrc = translation;\n                    translation = undefined;\n                }\n\n                return {\n                    rawLrc,\n                    translation,\n                };\n            }\n        } catch (e: any) {\n            // trace('插件获取歌词失败', e?.message, 'error');\n            // devLog('error', '插件获取歌词失败', e, e?.message);\n        }\n\n        if (lrcUrl) {\n            try {\n                rawLrc = (await axios.get(lrcUrl, { timeout: 5000 })).data;\n                return {\n                    rawLrc,\n                    lrc: lrcUrl,\n                    translation,\n                };\n            } catch {\n                lrcUrl = undefined;\n            }\n        }\n        // // 6. 如果是本地文件\n        // const isDownloaded = LocalMusicSheet.isLocalMusic(musicItem);\n        // if (musicItem.platform !== localPluginPlatform && isDownloaded) {\n        //     const res = await localFilePlugin.instance!.getLyric!(isDownloaded);\n        //     if (res) {\n        //         return res;\n        //     }\n        // }\n        // devLog('warn', '无歌词');\n\n        return null;\n    }\n\n    /** 获取专辑信息 */\n    async getAlbumInfo(\n        albumItem: IAlbum.IAlbumItem,\n        page = 1,\n    ): Promise<IPlugin.IAlbumInfoResult | null> {\n        if (!this.plugin.instance.getAlbumInfo) {\n            return {\n                albumItem,\n                musicList: (albumItem?.musicList ?? []).map((it) =>\n                    resetMediaItem(it, this.plugin.name),\n                ),\n                isEnd: true,\n            };\n        }\n        try {\n            const result = await this.plugin.instance.getAlbumInfo(\n                resetMediaItem(albumItem, undefined, true),\n                page,\n            );\n            if (!result) {\n                throw new Error();\n            }\n            result?.musicList?.forEach((_) => {\n                resetMediaItem(_, this.plugin.name);\n                _.album = albumItem.title;\n            });\n\n            if (page <= 1) {\n                // 合并信息\n                return {\n                    albumItem: { ...albumItem, ...(result?.albumItem ?? {}) },\n                    isEnd: result.isEnd === false ? false : true,\n                    musicList: result.musicList,\n                };\n            } else {\n                return {\n                    isEnd: result.isEnd === false ? false : true,\n                    musicList: result.musicList,\n                };\n            }\n        } catch (e: any) {\n            // trace('获取专辑信息失败', e?.message);\n            // devLog('error', '获取专辑信息失败', e, e?.message);\n\n            return null;\n        }\n    }\n\n    /** 获取歌单信息 */\n    async getMusicSheetInfo(\n        sheetItem: IMusic.IMusicSheetItem,\n        page = 1,\n    ): Promise<IPlugin.ISheetInfoResult | null> {\n        if (!this.plugin.instance.getMusicSheetInfo) {\n            return {\n                sheetItem,\n                musicList: sheetItem?.musicList ?? [],\n                isEnd: true,\n            };\n        }\n        try {\n            const result = await this.plugin.instance?.getMusicSheetInfo?.(\n                resetMediaItem(sheetItem, undefined, true),\n                page,\n            );\n            if (!result) {\n                throw new Error();\n            }\n            result?.musicList?.forEach((_) => {\n                resetMediaItem(_, this.plugin.name);\n            });\n\n            if (page <= 1) {\n                // 合并信息\n                return {\n                    sheetItem: { ...sheetItem, ...(result?.sheetItem ?? {}) },\n                    isEnd: result.isEnd === false ? false : true,\n                    musicList: result.musicList,\n                };\n            } else {\n                return {\n                    isEnd: result.isEnd === false ? false : true,\n                    musicList: result.musicList,\n                };\n            }\n        } catch (e: any) {\n            // trace('获取歌单信息失败', e, e?.message);\n            // devLog('error', '获取歌单信息失败', e, e?.message);\n\n            return null;\n        }\n    }\n\n    /** 查询作者信息 */\n    async getArtistWorks<T extends IArtist.ArtistMediaType>(\n        artistItem: IArtist.IArtistItem,\n        page: number,\n        type: T,\n    ): Promise<IPlugin.ISearchResult<T>> {\n        if (!this.plugin.instance.getArtistWorks) {\n            return {\n                isEnd: true,\n                data: [],\n            };\n        }\n        try {\n            const result = await this.plugin.instance.getArtistWorks(\n                artistItem,\n                page,\n                type,\n            );\n            if (!result.data) {\n                return {\n                    isEnd: true,\n                    data: [],\n                };\n            }\n            result.data?.forEach((_) => resetMediaItem(_, this.plugin.name));\n            return {\n                isEnd: result.isEnd ?? true,\n                data: result.data,\n            };\n        } catch (e: any) {\n            // trace('查询作者信息失败', e?.message);\n            // devLog('error', '查询作者信息失败', e, e?.message);\n            console.log(e);\n            throw e;\n        }\n    }\n\n    /** 导入歌单 */\n    async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> {\n        try {\n            const result =\n        (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? [];\n            result.forEach((_) => resetMediaItem(_, this.plugin.name));\n            return result;\n        } catch (e: any) {\n            console.log(e);\n            // devLog('error', '导入歌单失败', e, e?.message);\n\n            return [];\n        }\n    }\n    /** 导入单曲 */\n    async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> {\n        try {\n            const result = await this.plugin.instance?.importMusicItem?.(urlLike);\n            if (!result) {\n                throw new Error();\n            }\n            resetMediaItem(result, this.plugin.name);\n            return result;\n        } catch (e: any) {\n            // devLog('error', '导入单曲失败', e, e?.message);\n\n            return null;\n        }\n    }\n    /** 获取榜单 */\n    async getTopLists(): Promise<IMusic.IMusicSheetGroupItem[]> {\n        try {\n            const result = await this.plugin.instance?.getTopLists?.();\n            if (!result) {\n                throw new Error();\n            }\n            return result;\n        } catch (e: any) {\n            // devLog('error', '获取榜单失败', e, e?.message);\n            return [];\n        }\n    }\n    /** 获取榜单详情 */\n    async getTopListDetail(\n        topListItem: IMusic.IMusicSheetItem,\n        page: number,\n    ): Promise<IPlugin.ITopListInfoResult> {\n        try {\n            const result = await this.plugin.instance?.getTopListDetail?.(\n                topListItem,\n                page,\n            );\n            if (!result) {\n                throw new Error();\n            }\n            if (result.musicList) {\n                result.musicList.forEach((_) => resetMediaItem(_, this.plugin.name));\n            }\n            if (result.isEnd !== false) {\n                result.isEnd = true;\n            }\n            return result;\n        } catch (e: any) {\n            // devLog('error', '获取榜单详情失败', e, e?.message);\n            return {\n                isEnd: true,\n                topListItem,\n                musicList: [],\n            };\n        }\n    }\n\n    /** 获取推荐歌单的tag */\n    async getRecommendSheetTags(): Promise<IPlugin.IGetRecommendSheetTagsResult> {\n        try {\n            const result = await this.plugin.instance?.getRecommendSheetTags?.();\n            if (!result) {\n                throw new Error();\n            }\n            return result;\n        } catch (e: any) {\n            // devLog('error', '获取推荐歌单失败', e, e?.message);\n            return {\n                data: [],\n            };\n        }\n    }\n    /** 获取某个tag的推荐歌单 */\n    async getRecommendSheetsByTag(\n        tagItem: IMedia.IUnique,\n        page?: number,\n    ): Promise<ICommon.PaginationResponse<IMusic.IMusicSheetItem>> {\n        try {\n            const result = await this.plugin.instance?.getRecommendSheetsByTag?.(\n                tagItem,\n                page ?? 1,\n            );\n            if (!result) {\n                throw new Error();\n            }\n            if (result.isEnd !== false) {\n                result.isEnd = true;\n            }\n            if (!result.data) {\n                result.data = [];\n            }\n            result.data.forEach((item) => resetMediaItem(item, this.plugin.name));\n\n            return result;\n        } catch (e: any) {\n            // devLog('error', '获取推荐歌单详情失败', e, e?.message);\n            return {\n                isEnd: true,\n                data: [],\n            };\n        }\n    }\n\n    async getMusicComments(musicItem: IMusic.IMusicItem, page = 1): Promise<IPlugin.IGetCommentResult> {\n        try {\n            const result = await this.plugin.instance?.getMusicComments?.(\n                musicItem,\n                page,\n            );\n            if (!result) {\n                throw new Error();\n            }\n            return result;\n        } catch (e: any) {\n            return {\n                isEnd: true,\n                data: [],\n            };\n        }\n    }\n}\n"
  },
  {
    "path": "src/shared/plugin-manager/main/plugin.ts",
    "content": "import CryptoJs from \"crypto-js\";\nimport dayjs from \"dayjs\";\nimport axios from \"axios\";\nimport bigInt from \"big-integer\";\nimport qs from \"qs\";\nimport * as cheerio from \"cheerio\";\nimport he from \"he\";\nimport PluginMethods from \"./plugin-methods\";\nimport reactNativeCookies from \"./polyfill/react-native-cookies\";\nimport { app } from \"electron\";\nimport * as webdav from \"webdav\";\nimport AppConfig from \"@shared/app-config/main\";\nimport pluginStorage from \"@shared/plugin-manager/main/polyfill/storage\";\n\naxios.defaults.timeout = 15000;\n\nconst sha256 = CryptoJs.SHA256;\n\nexport enum PluginStateCode {\n    /** 版本不匹配 */\n    VersionNotMatch = \"VERSION NOT MATCH\",\n    /** 无法解析 */\n    CannotParse = \"CANNOT PARSE\",\n}\n\nconst packages: Record<string, any> = {\n    cheerio,\n    \"crypto-js\": CryptoJs,\n    axios,\n    dayjs,\n    \"big-integer\": bigInt,\n    qs,\n    he,\n    \"@react-native-cookies/cookies\": reactNativeCookies,\n    webdav,\n    \"musicfree/storage\": pluginStorage,\n};\n\nconst _require = (packageName: string) => {\n    const pkg = packages[packageName];\n    if (pkg) {\n        pkg.default = pkg;\n        return pkg;\n    }\n    return null;\n};\n\n// const _consoleBind = function (\n//     method: 'logger' | 'error' | 'info' | 'warn',\n//     ...args: any\n// ) {\n//     const fn = console[method];\n//     if (fn) {\n//         fn(...args);\n//         devLog(method, ...args);\n//     }\n// };\n\n// const _console = {\n//     logger: _consoleBind.bind(null, 'logger'),\n//     warn: _consoleBind.bind(null, 'warn'),\n//     info: _consoleBind.bind(null, 'info'),\n//     error: _consoleBind.bind(null, 'error'),\n// };\n\n//#region 插件类\nexport class Plugin {\n    /** 插件名 */\n    public name: string;\n    /** 插件的hash，作为唯一id */\n    public hash: string;\n    /** 插件状态信息 */\n    public stateCode?: PluginStateCode;\n    /** 插件的实例 */\n    public instance: IPlugin.IPluginInstance;\n    /** 插件路径 */\n    public path: string;\n    /** 插件方法 */\n    public methods: PluginMethods;\n\n    constructor(\n        funcCode: string | (() => IPlugin.IPluginInstance),\n        pluginPath: string,\n    ) {\n        let _instance: IPlugin.IPluginInstance;\n        const _module: any = { exports: {}, loaded: false };\n        let loadResolveCallback: () => void = null;\n        const ensurePluginInitialized = new Promise<void>((resolve) => {\n            loadResolveCallback = resolve;\n        });\n        try {\n            if (typeof funcCode === \"string\") {\n                // 插件的环境变量\n                const env = {\n                    getUserVariables: () => {\n                        return (\n                            AppConfig.getConfig(\"private.pluginMeta\")?.[this.name]\n                                ?.userVariables ?? {}\n                        );\n                    },\n                    os: process.platform,\n                    appVersion: app.getVersion(),\n                    lang: AppConfig.getConfig(\"normal.language\"),\n                };\n                const _process = {\n                    platform: process.platform,\n                    version: app.getVersion(),\n                    env,\n                    ensurePluginInitialized,\n                };\n\n                 \n                _instance = Function(`\n                    'use strict';\n                    return function(require, __musicfree_require, module, exports, console, env, process) {\n                        ${funcCode}\n                    }\n                `)()(\n                    _require,\n                    _require,\n                    _module,\n                    _module.exports,\n                    console,\n                    env,\n                    _process,\n                );\n                if (_module.exports.default) {\n                    _instance = _module.exports.default as IPlugin.IPluginInstance;\n                } else {\n                    _instance = _module.exports as IPlugin.IPluginInstance;\n                }\n                loadResolveCallback?.();\n\n\n            } else {\n                _instance = funcCode();\n            }\n            // 插件初始化后的一些操作\n            if (Array.isArray(_instance.userVariables)) {\n                _instance.userVariables = _instance.userVariables.filter(\n                    (it) => it?.key,\n                );\n            }\n            this.checkValid(_instance);\n        } catch (e: any) {\n            this.stateCode = PluginStateCode.CannotParse;\n            if (e?.stateCode) {\n                this.stateCode = e.stateCode;\n            }\n\n            _instance = e?.instance ?? {\n                _path: \"\",\n                platform: \"\",\n                appVersion: \"\",\n                async getMediaSource() {\n                    return null;\n                },\n                async search() {\n                    return {};\n                },\n                async getAlbumInfo() {\n                    return null;\n                },\n            };\n        }\n        this.instance = _instance;\n        this.path = pluginPath;\n        this.name = _instance.platform;\n        if (this.instance.platform === \"\" || this.instance.platform === undefined) {\n            this.hash = \"\";\n        } else {\n            if (typeof funcCode === \"string\") {\n                this.hash = sha256(funcCode).toString();\n            } else {\n                this.hash = sha256(funcCode.toString()).toString();\n            }\n        }\n        _module.loaded = true;\n\n        // 放在最后\n        this.methods = new PluginMethods(this);\n    }\n\n    private checkValid(_instance: IPlugin.IPluginInstance) {\n        /** 版本号校验 */\n        // if (\n        //     _instance.appVersion &&\n        //     !satisfies(DeviceInfo.getVersion(), _instance.appVersion)\n        // ) {\n        //     throw {\n        //         instance: _instance,\n        //         stateCode: PluginStateCode.VersionNotMatch,\n        //     };\n        // }\n        return true;\n    }\n}\n\n//#endregion\n"
  },
  {
    "path": "src/shared/plugin-manager/main/polyfill/react-native-cookies.ts",
    "content": "import { session } from \"electron\";\n\ninterface Cookie {\n    name: string;\n    value: string;\n    path?: string;\n    domain?: string;\n    version?: string;\n    expires?: string;\n    secure?: boolean;\n    httpOnly?: boolean;\n}\n\nexport interface Cookies {\n    [key: string]: Cookie;\n}\n\nasync function set(\n    url: string,\n    cookie: Cookie,\n): Promise<boolean> {\n    try {\n        await session.defaultSession.cookies.set({\n            url,\n            ...cookie,\n        });\n        return true;\n    } catch {\n        return false;\n    }\n}\n\nasync function get(url: string): Promise<Cookies> {\n    try {\n        const result = await session.defaultSession.cookies.get({\n            url,\n        });\n        const resultMap: Cookies = {};\n        for (const r of result) {\n            resultMap[r.name] = r;\n        }\n        return resultMap;\n    } catch {\n        return null;\n    }\n}\n\nasync function flush(): Promise<void> {\n    return session.defaultSession.cookies.flushStore();\n}\n\nexport default {\n    set,\n    get,\n    flush,\n};\n"
  },
  {
    "path": "src/shared/plugin-manager/main/polyfill/storage.ts",
    "content": "import { app } from \"electron\";\nimport path from \"path\";\nimport fs from \"fs/promises\";\nimport { rimraf } from \"rimraf\";\n\nconst MAX_STORAGE_SIZE = 1024 * 1024 * 10;\nlet storage: Record<string, string> = {};\nlet loaded = false;\n\nasync function loadStorage() {\n    if (loaded) {\n        return storage;\n    }\n    try {\n        const storagePath = path.resolve(app.getPath(\"appData\"), \"./musicfree-plugin-storage/chunk.json\");\n        const storageString = await fs.readFile(storagePath, \"utf-8\");\n        storage = JSON.parse(storageString);\n    } catch {\n        // pass\n    }\n    loaded = true;\n}\n\nasync function saveStorage(newStorage: Record<string, string>) {\n    const storageString = JSON.stringify(newStorage, undefined, 0);\n    if (Buffer.byteLength(storageString, \"utf-8\") > MAX_STORAGE_SIZE) {\n        throw new Error(\"Storage size exceeds limit\");\n    }\n\n    const storagePath = path.resolve(app.getPath(\"appData\"), \"./musicfree-plugin-storage/chunk.json\");\n\n    let fileExist = true;\n    try {\n        const stat = await fs.stat(storagePath);\n        if (!stat.isFile()) {\n            fileExist = false;\n            await rimraf(storagePath);\n        }\n    } catch {\n        fileExist = false;\n    }\n\n    if (!fileExist) {\n        await fs.mkdir(path.resolve(storagePath, \"..\"), {\n            recursive: true,\n        });\n    }\n    storage = newStorage;\n    await fs.writeFile(storagePath, storageString, \"utf-8\");\n\n}\n\nasync function setItem(key: string, value: unknown) {\n    if (!loaded) {\n        await loadStorage();\n    }\n    const newStorage = {\n        ...storage,\n        [key]: typeof value === \"string\" ? value : value?.toString?.(),\n    };\n    await saveStorage(newStorage);\n}\n\nasync function getItem(key: string) {\n    if (!loaded) {\n        await loadStorage();\n    }\n    return storage[key] ?? null;\n}\n\nasync function removeItem(key: string) {\n    if (!loaded) {\n        await loadStorage();\n    }\n    const newStorage = {\n        ...storage,\n    };\n    delete newStorage[key];\n    await saveStorage(newStorage);\n}\n\nexport default {\n    setItem,\n    getItem,\n    removeItem,\n};\n"
  },
  {
    "path": "src/shared/plugin-manager/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\n\nipcRenderer.on(\"@/shared/plugin-manager/sync-plugins\", (_evt, newPlugins) => {\n    pluginUpdateCallback?.(newPlugins);\n});\n\nlet pluginUpdateCallback: (plugins: IPlugin.IPluginDelegate[]) => void;\n\nfunction onPluginUpdated(callback: (plugins: IPlugin.IPluginDelegate[]) => void) {\n    pluginUpdateCallback = callback;\n}\n\n\ninterface IPluginDelegateLike {\n    platform?: string;\n    hash?: string;\n}\n\nasync function callPluginMethod<\n    T extends keyof IPlugin.IPluginInstanceMethods,\n>(\n    pluginDelegate: IPluginDelegateLike,\n    method: T,\n    ...args: Parameters<IPlugin.IPluginInstanceMethods[T]>\n) {\n    return (await ipcRenderer.invoke(\"@shared/plugin-manager/call-plugin-method\", {\n        hash: pluginDelegate.hash,\n        platform: pluginDelegate.platform,\n        method,\n        args,\n    })) as ReturnType<IPlugin.IPluginInstanceMethods[T]>;\n}\n\nasync function reloadPlugins() {\n    const result = await ipcRenderer.invoke(\"@shared/plugin-manager/load-all-plugins\");\n    pluginUpdateCallback?.(result);\n}\n\nasync function uninstallPlugin(hash: string) {\n    await ipcRenderer.invoke(\"@shared/plugin-manager/uninstall-plugin\", hash);\n}\n\nasync function updateAllPlugins() {\n    ipcRenderer.emit(\"@shared/plugin-manager/update-all-plugins\");\n}\n\nasync function installPluginFromRemote(url: string) {\n    return await ipcRenderer.invoke(\"@shared/plugin-manager/install-plugin-remote\", url);\n}\n\nasync function installPluginFromLocal(url: string) {\n    return await ipcRenderer.invoke(\"@shared/plugin-manager/install-plugin-local\", url);\n}\n\nconst mod = {\n    onPluginUpdated,\n    callPluginMethod,\n    reloadPlugins,\n    uninstallPlugin,\n    updateAllPlugins,\n    installPluginFromLocal,\n    installPluginFromRemote,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/plugin-manager\", mod);\n"
  },
  {
    "path": "src/shared/plugin-manager/renderer.ts",
    "content": "import Store from \"@/common/store\";\nimport AppConfig from \"@shared/app-config/renderer\";\nimport useAppConfig from \"@/hooks/useAppConfig\";\nimport { useMemo } from \"react\";\n\ninterface IPluginDelegateLike {\n    platform?: string;\n    hash?: string;\n}\n\ninterface IMod {\n    onPluginUpdated: (callback: (plugins: IPlugin.IPluginDelegate[]) => void) => void,\n\n    callPluginMethod<\n        T extends keyof IPlugin.IPluginInstanceMethods,\n    >(\n        pluginDelegate: IPluginDelegateLike,\n        method: T,\n        ...args: Parameters<IPlugin.IPluginInstanceMethods[T]>\n    ): ReturnType<IPlugin.IPluginInstanceMethods[T]>,\n\n    reloadPlugins: () => Promise<void>;\n    uninstallPlugin: (hash: string) => Promise<void>;\n    updateAllPlugins: () => Promise<void>;\n    installPluginFromRemote: (url: string) => Promise<void>,\n    installPluginFromLocal: (rawCode: string) => Promise<void>,\n}\n\nconst mod = window[\"@shared/plugin-manager\" as any] as unknown as IMod;\n\n\nconst delegatePluginsStore = new Store<IPlugin.IPluginDelegate[]>([]);\n\nmod.onPluginUpdated((plugins) => {\n    delegatePluginsStore.setValue(plugins);\n});\n\nfunction getSupportedPlugin(\n    featureMethod: keyof IPlugin.IPluginInstanceMethods,\n) {\n    return delegatePluginsStore\n        .getValue()\n        .filter((_) => _.supportedMethod.includes(featureMethod));\n}\n\nfunction getSortedSupportedPlugin(\n    featureMethod: keyof IPlugin.IPluginInstanceMethods,\n) {\n    const meta = AppConfig.getConfig(\"private.pluginMeta\") ?? {};\n    return delegatePluginsStore\n        .getValue()\n        .filter((_) => _.supportedMethod.includes(featureMethod))\n        .sort((a, b) => {\n            return (meta[a.platform]?.order ?? Infinity) -\n            (meta[b?.platform]?.order ?? Infinity) <\n            0\n                ? -1\n                : 1;\n        });\n}\n\nfunction getSearchablePlugins(\n    supportedSearchType?: IMedia.SupportMediaType,\n) {\n    return getSupportedPlugin(\"search\").filter((_) =>\n        supportedSearchType && _.supportedSearchType\n            ? _.supportedSearchType.includes(supportedSearchType)\n            : true,\n    );\n}\n\n\nfunction getSortedSearchablePlugins(\n    supportedSearchType?: IMedia.SupportMediaType,\n) {\n    return getSortedSupportedPlugin(\"search\").filter((_) =>\n        supportedSearchType && _.supportedSearchType\n            ? _.supportedSearchType.includes(supportedSearchType)\n            : true,\n    );\n}\n\nfunction getPluginByHash(hash: string) {\n    return delegatePluginsStore.getValue().find((item) => item.hash === hash);\n}\n\nfunction getPluginByPlatform(platform: string) {\n    return delegatePluginsStore.getValue().find((item) => item.platform === platform);\n}\n\nfunction isSupportFeatureMethod(platform: string, featureMethod: keyof IPlugin.IPluginInstanceMethods) {\n    if (!platform) {\n        return false;\n    }\n    return delegatePluginsStore.getValue().find((item) => item.platform === platform)?.supportedMethod?.includes?.(featureMethod) ?? false;\n}\n\n\nfunction getPluginPrimaryKey(pluginItem: IPluginDelegateLike) {\n    return (\n        delegatePluginsStore\n            .getValue()\n            .find((it) => it.platform === pluginItem.platform)?.primaryKey ?? []\n    );\n}\n\n\nasync function setup() {\n    await mod.reloadPlugins();\n}\n\nconst PluginManager = {\n    setup,\n    getSortedSupportedPlugin,\n    getSupportedPlugin,\n    getSearchablePlugins,\n    getSortedSearchablePlugins,\n    getPluginByHash,\n    getPluginByPlatform,\n    isSupportFeatureMethod,\n    getPluginPrimaryKey,\n    callPluginDelegateMethod: mod.callPluginMethod,\n    updateAllPlugins: mod.updateAllPlugins,\n    uninstallPlugin: mod.uninstallPlugin,\n    installPluginFromRemote: mod.installPluginFromRemote,\n    installPluginFromLocal: mod.installPluginFromLocal,\n};\n\nexport default PluginManager;\n\nexport function useSupportedPlugin(\n    featureMethod: keyof IPlugin.IPluginInstanceMethods,\n) {\n    return delegatePluginsStore\n        .useValue()\n        .filter((_) => _.supportedMethod.includes(featureMethod));\n}\n\nexport function useSortedSupportedPlugin(\n    featureMethod: keyof IPlugin.IPluginInstanceMethods,\n) {\n    const meta = AppConfig.getConfig(\"private.pluginMeta\") ?? {};\n    return delegatePluginsStore\n        .useValue()\n        .filter((_) => _.supportedMethod.includes(featureMethod))\n        .sort((a, b) => {\n            return (meta[a.platform]?.order ?? Infinity) -\n            (meta[b?.platform]?.order ?? Infinity) <\n            0\n                ? -1\n                : 1;\n        });\n}\n\nexport function useSortedPlugins() {\n    const plugins = delegatePluginsStore.useValue();\n    const meta = useAppConfig(\"private.pluginMeta\") ?? {};\n\n    return useMemo(() => {\n        return [...plugins].sort((a, b) => {\n            return (meta[a.platform]?.order ?? Infinity) -\n            (meta[b?.platform]?.order ?? Infinity) <\n            0\n                ? -1\n                : 1;\n        });\n    }, [plugins, meta]);\n}\n"
  },
  {
    "path": "src/shared/service-manager/common.ts",
    "content": "export enum ServiceName {\n    RequestForwarder = \"request-forwarder\",\n}\n"
  },
  {
    "path": "src/shared/service-manager/main.ts",
    "content": "import { ChildProcess, fork } from \"child_process\";\nimport { app, ipcMain } from \"electron\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport { ServiceName } from \"@shared/service-manager/common\";\nimport getResourcePath from \"@/common/get-resource-path\";\n\n\nclass ServiceInstance {\n    private serviceProcess: ChildProcess = null;\n    private retryTimeOut = 6000;\n    private started = false;\n    private subprocessName: string;\n\n    private hostChangeCallback: (host: string | null) => void;\n\n    public serviceName: string;\n\n    constructor(serviceName: string, subprocessPath: string) {\n        this.serviceName = serviceName;\n        this.subprocessName = subprocessPath;\n    }\n\n\n    onHostChange(callback: (host: string | null) => void) {\n        this.hostChangeCallback = callback;\n    }\n\n\n    start() {\n        if (this.started) {\n            return;\n        }\n        this.started = true;\n        const servicePath = getResourcePath(\".service/\" + this.subprocessName + \".js\");\n        this.serviceProcess = fork(servicePath);\n\n        interface IMessage {\n            type: \"port\",\n            port: number\n        }\n\n        this.serviceProcess.on(\"message\", (msg: IMessage) => {\n            const host = \"http://127.0.0.1:\" + msg.port;\n            this.hostChangeCallback(host);\n        });\n\n        this.serviceProcess.on(\"error\", () => {\n            if (this.started) {\n                setTimeout(() => {\n                    this.start(); // 自动重启子进程\n                }, this.retryTimeOut);\n\n                this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2;\n            }\n        });\n\n        this.serviceProcess.on(\"exit\", (code) => {\n            if (this.started) {\n                console.error(`Service exited with code ${code}. Restarting...`);\n                setTimeout(() => {\n                    this.start(); // 自动重启子进程\n                }, this.retryTimeOut);\n\n                this.retryTimeOut = this.retryTimeOut > 300000 ? 300000 : this.retryTimeOut * 2;\n            }\n        });\n    }\n\n    stop() {\n        this.started = false;\n        if (!this.serviceProcess.killed) {\n            this.serviceProcess.removeAllListeners();\n            this.serviceProcess.kill();\n            this.serviceProcess = null;\n            this.retryTimeOut = 6000;\n            this.hostChangeCallback(null);\n        }\n    }\n}\n\ninterface IServiceData {\n    instance: ServiceInstance;\n    host: string | null;\n}\n\nclass ServiceManager {\n    private windowManager: IWindowManager;\n    private serviceMap = new Map<ServiceName, IServiceData>();\n\n\n    private addService(serviceName: ServiceName) {\n        const instance = new ServiceInstance(serviceName, serviceName);\n        this.serviceMap.set(serviceName, { instance, host: null });\n        instance.onHostChange((host) => {\n            const mainWindow = this.windowManager?.mainWindow;\n            if (mainWindow) {\n                mainWindow.webContents.send(\"@shared/service-manager/host-changed\", serviceName, host);\n            }\n            this.serviceMap.get(serviceName).host = host;\n        });\n\n        return instance;\n    }\n\n    startService(serviceName: ServiceName) {\n        this.serviceMap.get(serviceName)?.instance?.start?.();\n    }\n\n    stopService(serviceName: ServiceName) {\n        this.serviceMap.get(serviceName)?.instance?.stop?.();\n    }\n\n    setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n\n        app.on(\"before-quit\", () => {\n            if (!windowManager.mainWindow?.isDestroyed()) {\n                this.serviceMap.forEach((val) => {\n                    val.instance.stop();\n                });\n            }\n        });\n\n        // put services here\n        this.addService(ServiceName.RequestForwarder).start();\n\n\n        ipcMain.handle(\"@shared/service-manager/get-service-hosts\", () => {\n            const serviceHosts: Record<string, string> = {};\n            this.serviceMap.forEach((val, key) => {\n                if (val.host) {\n                    serviceHosts[key] = val.host;\n                }\n            });\n            return serviceHosts;\n        });\n\n\n    }\n}\n\n\nexport default new ServiceManager();\n"
  },
  {
    "path": "src/shared/service-manager/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport { ServiceName } from \"@shared/service-manager/common\";\n\nconst serviceHostMap = new Map<ServiceName, string>();\n\nipcRenderer.on(\"@shared/service-manager/host-changed\", (_evt, serviceName: ServiceName, host: string | null) => {\n    if (host) {\n        serviceHostMap.set(serviceName, host);\n    } else {\n        serviceHostMap.delete(serviceName);\n    }\n});\n\n\n\nasync function setup() {\n    const hosts = (await ipcRenderer.invoke(\"@shared/service-manager/get-service-hosts\")) || {};\n    const serviceNames = Object.keys(hosts);\n    for (const serviceName of serviceNames) {\n        serviceHostMap.set(serviceName as any, hosts[serviceName]);\n    }\n}\n\nfunction getServiceHost(serviceName: ServiceName) {\n    return serviceHostMap.get(serviceName);\n}\n\nconst mod = {\n    setup,\n    getServiceHost,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/service-manager\", mod);\n\n"
  },
  {
    "path": "src/shared/service-manager/renderer.ts",
    "content": "import { ServiceName } from \"@shared/service-manager/common\";\n\ninterface IMod {\n    setup: () => Promise<void>;\n    getServiceHost: (serviceName: ServiceName) => string | null;\n}\n\nconst mod = window[\"@shared/service-manager\" as any] as unknown as IMod;\n\n\nclass RequestForwarderService {\n\n    static forwardRequest(url: string, method?: string, headers?: Record<any, any>): string | null {\n        const host = mod.getServiceHost(ServiceName.RequestForwarder);\n        if (!host) {\n            return null;\n        }\n\n        const fUrl = new URL(host);\n        fUrl.searchParams.set(\"url\", url);\n        if (method) {\n            fUrl.searchParams.set(\"method\", method);\n        }\n        if (headers) {\n            fUrl.searchParams.set(\"headers\", JSON.stringify(headers));\n        }\n        return fUrl.toString();\n    }\n}\n\n\n\nconst ServiceManager = {\n    setup: mod.setup,\n    RequestForwarderService,\n};\n\nexport default ServiceManager;\n"
  },
  {
    "path": "src/shared/short-cut/main.ts",
    "content": "import { globalShortcut, ipcMain } from \"electron\";\nimport AppConfig from \"@shared/app-config/main\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport { shortCutKeys, shortCutKeysCommands } from \"@/common/constant\";\nimport messageBus from \"@shared/message-bus/main\";\n\ntype IShortCutKeys = keyof IAppConfig[\"shortCut.shortcuts\"];\n\nclass ShortCut {\n    async setup() {\n        await this.registerAllGlobalShortCuts();\n\n        ipcMain.on(\"@shared/short-cut/register-global-short-cut\", async (_, key, shortCut) => {\n            await this.registerGlobalShortCut(key, shortCut);\n        });\n\n        ipcMain.on(\"@shared/short-cut/unregister-global-short-cut\", async (_, key) => {\n            await this.unregisterGlobalShortCut(key);\n        });\n    }\n\n    public async registerAllGlobalShortCuts() {\n        try {\n            const shortCuts = AppConfig.getConfig(\"shortCut.shortcuts\");\n            for (const shortCutKey of shortCutKeys) {\n                const globalShortCutConfig = shortCuts?.[shortCutKey]?.global;\n\n                if (globalShortCutConfig?.length) {\n                    await this.registerGlobalShortCut(shortCutKey, globalShortCutConfig);\n                }\n            }\n        } catch {\n            // pass;\n        }\n    }\n\n    public unregisterAllGlobalShortCuts() {\n        globalShortcut.unregisterAll();\n    }\n\n\n    public async registerGlobalShortCut(key: IShortCutKeys, shortCut: string[]) {\n        try {\n            if (shortCut.length) {\n                // 1. 取之前的快捷键\n                const prevConfig = AppConfig.getConfig(\"shortCut.shortcuts\");\n\n                if (prevConfig?.[key]?.global?.length) {\n                    globalShortcut.unregister(prevConfig[key].global.join(\"+\"));\n                }\n\n                // 2. 注册新的快捷键\n                const reg = globalShortcut.register(shortCut.join(\"+\"), () => {\n                    messageBus.sendCommand(shortCutKeysCommands[key]);\n                });\n\n                // 3. 合并配置\n                const newConfig = {\n                    ...(prevConfig || {} as any),\n                    [key]: {\n                        ...(prevConfig?.[key] || {}),\n                        global: reg ? shortCut : null,\n                    },\n                };\n                // 4. 更新配置\n                AppConfig.setConfig({\n                    \"shortCut.shortcuts\": newConfig,\n                });\n            }\n        } catch {\n            // pass\n        }\n    }\n\n\n    public async unregisterGlobalShortCut(key: IShortCutKeys) {\n        const prevShortCut = AppConfig.getConfig(\"shortCut.shortcuts\")?.[key]?.global;\n        if (prevShortCut?.length) {\n            // 1. 注销快捷键\n            globalShortcut.unregister(prevShortCut.join(\"+\"));\n            // 2. 更新配置\n            const prevConfig = AppConfig.getConfig(\"shortCut.shortcuts\");\n            const newConfig = {\n                ...(prevConfig || {} as any),\n                [key]: {\n                    ...(prevConfig?.[key] || {}),\n                    global: null,\n                },\n            } as IAppConfig[\"shortCut.shortcuts\"];\n            AppConfig.setConfig({\n                \"shortCut.shortcuts\": newConfig,\n            });\n        }\n    }\n}\n\n\nconst shortCut = new ShortCut();\nexport default shortCut;\n"
  },
  {
    "path": "src/shared/short-cut/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\n\n\nfunction registerGlobalShortCut(key: string, shortCut: string[]) {\n    ipcRenderer.send(\"@shared/short-cut/register-global-short-cut\", key, shortCut);\n}\n\nfunction unregisterGlobalShortCut(key: string) {\n    ipcRenderer.send(\"@shared/short-cut/unregister-global-short-cut\", key);\n}\n\nconst mod = {\n    registerGlobalShortCut,\n    unregisterGlobalShortCut,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/short-cut\", mod);\n"
  },
  {
    "path": "src/shared/short-cut/renderer.ts",
    "content": "import AppConfig from \"@shared/app-config/renderer\";\nimport { IAppConfig } from \"@/types/app-config\";\nimport { shortCutKeys, shortCutKeysCommands } from \"@/common/constant\";\nimport hotkeys from \"hotkeys-js\";\nimport messageBus from \"@shared/message-bus/renderer/main\";\n\ntype IShortCutKeys = keyof IAppConfig[\"shortCut.shortcuts\"];\n\ninterface IMod {\n    registerGlobalShortCut: (key: IShortCutKeys, shortCut: string[]) => void;\n    unregisterGlobalShortCut: (key: IShortCutKeys) => void;\n}\n\nconst mod = window[\"@shared/short-cut\" as any] as unknown as IMod;\n\nconst originalHotkeysFilter = hotkeys.filter;\n\nhotkeys.filter = (event) => {\n    const target = event.target as HTMLElement;\n    if (target.dataset[\"capture\"] === \"true\") {\n        return true;\n    }\n    return originalHotkeysFilter(event);\n};\n\nclass ShortCut {\n    private localShortCutCallbackMap = new Map<string, (...args: any[]) => void>();\n\n    setup() {\n        try {\n            const shortCuts = AppConfig.getConfig(\"shortCut.shortcuts\");\n            for (const shortCutKey of shortCutKeys) {\n                const localShortCutConfig = shortCuts?.[shortCutKey]?.local;\n                if (localShortCutConfig?.length) {\n                    this.registerLocalShortCut(shortCutKey, localShortCutConfig);\n                }\n            }\n        } catch {\n            // pass\n        }\n    }\n\n\n    registerLocalShortCut(key: IShortCutKeys, shortCut: string[]) {\n        if (!shortCut?.length) {\n            return;\n        }\n        this.unregisterLocalShortCut(key);\n        const callback = (evt: KeyboardEvent) => {\n            if (AppConfig.getConfig(\"shortCut.enableLocal\")) {\n                evt.preventDefault();\n                messageBus.sendCommand(shortCutKeysCommands[key]);\n            }\n        };\n        this.localShortCutCallbackMap.set(key as string, callback);\n        hotkeys(shortCut.join(\"+\"), \"all\", callback);\n\n        const shortCuts = AppConfig.getConfig(\"shortCut.shortcuts\");\n        AppConfig.setConfig({\n            \"shortCut.shortcuts\": {\n                ...(shortCuts || {} as any),\n                [key]: {\n                    ...(shortCuts?.[key] || {}),\n                    local: shortCut,\n                },\n            },\n        });\n    }\n\n    unregisterLocalShortCut(key: IShortCutKeys) {\n        const shortCuts = AppConfig.getConfig(\"shortCut.shortcuts\");\n        const prevShortCut = shortCuts?.[key]?.local;\n        if (prevShortCut?.length) {\n            hotkeys.unbind(prevShortCut.join(\"+\"), \"all\", this.localShortCutCallbackMap.get(key as string));\n            this.localShortCutCallbackMap.delete(key as string);\n\n            AppConfig.setConfig({\n                \"shortCut.shortcuts\": {\n                    ...(shortCuts || {} as any),\n                    [key]: {\n                        ...(shortCuts[key] || {}),\n                        local: null,\n                    },\n                },\n            });\n        }\n    }\n\n    registerGlobalShortCut(key: IShortCutKeys, shortCut: string[]) {\n        mod.registerGlobalShortCut(key, shortCut);\n    }\n\n    unregisterGlobalShortCut(key: IShortCutKeys) {\n        mod.unregisterGlobalShortCut(key);\n    }\n}\n\n\nconst shortCut = new ShortCut();\nexport default shortCut;\n"
  },
  {
    "path": "src/shared/themepack/main.ts",
    "content": "export default {};"
  },
  {
    "path": "src/shared/themepack/preload.ts",
    "content": "import { addFileScheme, addTailSlash } from \"@/common/file-util\";\nimport path from \"path\";\nimport fs from \"fs/promises\";\nimport { Readable } from \"stream\";\nimport { rimraf } from \"rimraf\";\nimport { nanoid } from \"nanoid\";\nimport { createReadStream, createWriteStream } from \"original-fs\";\nimport unzipper from \"unzipper\";\nimport { getGlobalContext } from \"../global-context/preload\";\nimport { contextBridge } from \"electron\";\nimport CryptoJS from \"crypto-js\";\nimport debounce from \"@/common/debounce\";\n\nconst themeNodeId = \"themepack-node\";\nconst themePathKey = \"themepack-path\";\n\nconst validIframeMap = new Map<\n  \"app\" | \"header\" | \"body\" | \"music-bar\" | \"side-bar\" | \"page\",\n  HTMLIFrameElement | null\n>([\n    [\"app\", null],\n    [\"header\", null],\n    [\"body\", null],\n    [\"music-bar\", null],\n    [\"side-bar\", null],\n    [\"page\", null],\n]);\n\nconst themePackBasePath: string = path.resolve(\n    getGlobalContext().appPath.userData,\n    \"./musicfree-themepacks\",\n);\n\n/**\n * TODO: iframe需要运行在独立的进程中，不然会影响到app的fps 得想个办法\n */\n\n/** 选择某个主题 */\nasync function selectTheme(themePack: ICommon.IThemePack | null) {\n    const themeNode = document.querySelector(`#${themeNodeId}`);\n    if (themePack === null) {\n    // 移除\n        themeNode.innerHTML = \"\";\n        validIframeMap.forEach((value, key) => {\n            if (value !== null) {\n                value.remove();\n                validIframeMap.set(key, null);\n            }\n        });\n        localStorage.removeItem(themePathKey);\n    } else {\n        const rawStyle = await fs.readFile(\n            path.resolve(themePack.path, \"index.css\"),\n            \"utf-8\",\n        );\n        themeNode.innerHTML = replaceAlias(rawStyle, themePack.path);\n\n        if (themePack.iframe) {\n            validIframeMap.forEach(async (value, key) => {\n                const themePackIframeSource = themePack.iframe[key];\n                if (themePackIframeSource) {\n                    // 如果有，且当前也有\n                    let iframeNode = null;\n                    if (value !== null) {\n                        // 移除旧的\n                        value.remove();\n                        validIframeMap.set(key, null);\n                    }\n                    // 新的iframe\n                    iframeNode = document.createElement(\"iframe\");\n                    iframeNode.scrolling = \"no\";\n                    document.querySelector(`.${key}-container`)?.prepend?.(iframeNode);\n                    validIframeMap.set(key, iframeNode);\n\n                    if (themePackIframeSource.startsWith(\"http\")) {\n                        iframeNode.src = themePackIframeSource;\n                    } else {\n                        const rawHtml = await fs.readFile(\n                            replaceAlias(themePackIframeSource, themePack.path, false),\n                            \"utf-8\",\n                        );\n                        iframeNode.contentWindow.document.open();\n                        iframeNode.contentWindow.document.write(\n                            replaceAlias(rawHtml, themePack.path),\n                        );\n                        iframeNode.contentWindow.document.close();\n                    }\n                } else if (value) {\n                    value.remove();\n                    validIframeMap.set(key, null);\n                }\n            });\n        } else {\n            validIframeMap.forEach((value, key) => {\n                if (value !== null) {\n                    value.remove();\n                    validIframeMap.set(key, null);\n                }\n            });\n        }\n        localStorage.setItem(themePathKey, themePack.path);\n    }\n}\n\n/** 替换标记 */\nfunction replaceAlias(\n    rawText: string,\n    basePath: string,\n    withFileScheme = true,\n) {\n    return rawText.replaceAll(\n        \"@/\",\n        addTailSlash(withFileScheme ? addFileScheme(basePath) : basePath),\n    );\n}\n\nasync function checkPath() {\n    // 路径:\n    try {\n        const res = await fs.stat(themePackBasePath);\n        if (!res.isDirectory()) {\n            await rimraf(themePackBasePath);\n            throw new Error();\n        }\n    } catch {\n        fs.mkdir(themePackBasePath, {\n            recursive: true,\n        });\n    }\n}\n\nconst downloadResponse = async (response: Response, filePath: string) => {\n    const reader = response.body.getReader();\n    let size = 0;\n\n    return new Promise<void>((resolve, reject) => {\n        const rs = new Readable();\n\n        rs._read = async () => {\n            const result = await reader.read();\n            if (!result.done) {\n                rs.push(Buffer.from(result.value));\n                size += result.value.byteLength;\n            } else {\n                rs.push(null);\n                return;\n            }\n        };\n        rs.on(\"error\", reject);\n\n        const stm = rs.pipe(createWriteStream(filePath));\n\n        stm.on(\"finish\", resolve);\n        stm.on(\"close\", resolve);\n        stm.on(\"error\", reject);\n    });\n};\n\nasync function parseThemePack(\n    themePackPath: string,\n): Promise<ICommon.IThemePack | null> {\n    try {\n        if (!themePackPath) {\n            return null;\n        }\n        const packContent = await fs.readdir(themePackPath);\n        if (\n            !(\n                packContent.includes(\"config.json\") && packContent.includes(\"index.css\")\n            )\n        ) {\n            throw new Error(\"Not Valid Theme Pack\");\n        }\n\n        const rawConfig = await fs.readFile(\n            path.resolve(themePackPath, \"config.json\"),\n            \"utf-8\",\n        );\n        // 读取json\n        const jsonData = JSON.parse(rawConfig);\n\n        const themePack: ICommon.IThemePack = {\n            ...jsonData,\n            hash: CryptoJS.MD5(rawConfig).toString(CryptoJS.enc.Hex),\n            preview: jsonData.preview?.startsWith?.(\"#\")\n                ? jsonData.preview\n                : jsonData.preview?.replace?.(\n                    \"@/\",\n                    addTailSlash(addFileScheme(themePackPath)),\n                ),\n            path: themePackPath,\n        };\n        return themePack;\n    } catch (e) {\n        console.warn(e);\n        return null;\n    }\n}\n\n/** 加载所有的主题包 */\nasync function initCurrentTheme() {\n    try {\n        await checkPath();\n        const currentThemePath = localStorage.getItem(themePathKey);\n\n        console.log(currentThemePath, themePathKey);\n\n        const currentTheme: ICommon.IThemePack | null = await parseThemePack(\n            currentThemePath,\n        );\n        return currentTheme;\n    } catch (e) {\n        return null;\n    }\n}\n\nasync function loadThemePacks() {\n    const themePackDirNames = await fs.readdir(themePackBasePath);\n    // 读取所有的文件夹\n    const parsedThemePacks: ICommon.IThemePack[] = [];\n\n    for (const themePackDir of themePackDirNames) {\n        try {\n            const parsedThemePack = await parseThemePack(\n                path.resolve(themePackBasePath, themePackDir),\n            );\n            if (parsedThemePack) {\n                parsedThemePacks.push(parsedThemePack);\n            }\n        } catch {}\n    }\n    return parsedThemePacks;\n}\n\nasync function installRemoteThemePack(remoteUrl: string) {\n    const cacheFilePath = path.resolve(\n        getGlobalContext().appPath.temp,\n        `./${nanoid()}.mftheme`,\n    );\n    try {\n        const resp = await fetch(remoteUrl);\n        await downloadResponse(resp, cacheFilePath);\n        const config = await installThemePack(cacheFilePath);\n        if (!config) {\n            throw new Error(\"Download fail\");\n        }\n        return config;\n    } catch (e: any) {\n        throw e;\n    } finally {\n        await rimraf(cacheFilePath);\n    }\n}\n\nasync function installThemePack(themePackPath: string) {\n    // 第一步: 移动到安装文件夹\n    try {\n        const cacheFolder = path.resolve(themePackBasePath, nanoid(12));\n        await createReadStream(themePackPath)\n            .pipe(\n                unzipper.Extract({\n                    path: cacheFolder,\n                }),\n            )\n            .promise();\n        const parsedThemePack = await parseThemePack(cacheFolder);\n\n        if (parsedThemePack) {\n            parsedThemePack.path = cacheFolder;\n            return parsedThemePack;\n        } else {\n            // 无效的主题包\n            await rimraf(cacheFolder);\n            return null;\n        }\n    } catch (e) {\n        return null;\n    }\n}\n\nasync function uninstallThemePack(themePack: ICommon.IThemePack) {\n    return await rimraf(themePack.path);\n}\n\nexport const mod = {\n    selectTheme,\n    initCurrentTheme,\n    loadThemePacks,\n    installThemePack,\n    uninstallThemePack,\n    installRemoteThemePack,\n    replaceAlias,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/themepack\", mod);\n"
  },
  {
    "path": "src/shared/themepack/renderer.ts",
    "content": "import Store from \"@/common/store\";\nimport type { IMod } from \"./type\";\nimport { toast } from \"react-toastify\";\nimport { useEffect } from \"react\";\nimport debounce from \"@/common/debounce\";\n\nconst mod = window[\"@shared/themepack\" as any] as unknown as IMod;\n\n// 所有本地主题包\nconst localThemePacksStore = new Store<Array<ICommon.IThemePack | null>>([]);\n// 当前选中的主题包\nconst currentThemePackStore = new Store<ICommon.IThemePack | null>(null);\n\nasync function selectTheme(themePack: ICommon.IThemePack | null) {\n    if (!themePack?.hash) {\n        themePack = null;\n    }\n    await mod.selectTheme(themePack);\n    currentThemePackStore.setValue(themePack);\n}\n\nasync function selectThemeByHash(hash: string) {\n    const targetTheme = localThemePacksStore\n        .getValue()\n        .find((it) => it.hash === hash);\n\n    if (targetTheme) {\n        await mod.selectTheme(targetTheme);\n        currentThemePackStore.setValue(targetTheme);\n    }\n}\n\nlet themePacksLoaded = false;\nasync function setupThemePacks() {\n    try {\n        const currentTheme = await mod.initCurrentTheme();\n        // 选中主题\n        await selectTheme(currentTheme);\n        // 调度\n        requestIdleCallback(() => {\n            if (!themePacksLoaded) {\n                mod.loadThemePacks();\n            }\n        });\n\n        window.onresize = debounce(() => {\n            mod.selectTheme(currentThemePackStore.getValue());\n        }, 150, {\n            leading: false,\n            trailing: true,\n        });\n\n    } catch {\n    // pass\n    }\n}\n\nasync function loadThemePacks() {\n    themePacksLoaded = true;\n\n    const themePacks = await mod.loadThemePacks();\n    localThemePacksStore.setValue(themePacks);\n}\n\nasync function installThemePack(themePackPath: string) {\n    const themePackConfig = await mod.installThemePack(themePackPath);\n    if (themePackConfig) {\n        localThemePacksStore.setValue((prev) => [...prev, themePackConfig]);\n    }\n    return themePackConfig;\n}\n\n/**\n *\n * @param remoteUrl 主题包地址\n * @param id 可选，如果有主题id的话会替换掉本地的资源\n * @returns\n */\nasync function installRemoteThemePack(remoteUrl: string, id?: string) {\n    const themePackConfig = await mod.installRemoteThemePack(remoteUrl);\n\n    let oldThemeConfig: ICommon.IThemePack | null = null;\n    if (id) {\n        oldThemeConfig = localThemePacksStore.getValue().find((it) => it.id === id);\n        if (oldThemeConfig) {\n            mod.uninstallThemePack(oldThemeConfig);\n        }\n    }\n    if (themePackConfig) {\n        localThemePacksStore.setValue((prev) =>\n            [themePackConfig].concat(\n                oldThemeConfig\n                    ? prev.filter((it) => it.hash !== oldThemeConfig.hash)\n                    : prev,\n            ),\n        );\n    }\n    return themePackConfig;\n}\n\nasync function uninstallThemePack(themePack: ICommon.IThemePack) {\n    try {\n        await mod.uninstallThemePack(themePack);\n        localThemePacksStore.setValue((prev) =>\n            prev.filter((it) => it?.path !== themePack.path),\n        );\n        if (currentThemePackStore.getValue()?.path === themePack.path) {\n            selectTheme(null);\n        }\n    } catch {\n        toast.error(\"卸载失败\");\n    }\n}\n\nfunction useLocalThemePacks() {\n    const val = localThemePacksStore.useValue();\n\n    useEffect(() => {\n        if (!themePacksLoaded) {\n            loadThemePacks();\n        }\n    }, []);\n\n    return val;\n}\n\nconst ThemePack = {\n    selectTheme,\n    selectThemeByHash,\n    setupThemePacks,\n    loadThemePacks,\n    installThemePack,\n    installRemoteThemePack,\n    uninstallThemePack,\n    replaceAlias: mod.replaceAlias,\n    useLocalThemePacks,\n    useCurrentThemePack: currentThemePackStore.useValue,\n};\n\nexport default ThemePack;\n"
  },
  {
    "path": "src/shared/themepack/type.d.ts",
    "content": "export type IMod = typeof import(\"./preload\").mod;\n"
  },
  {
    "path": "src/shared/utils/main.ts",
    "content": "import { app, BrowserWindow, dialog, ipcMain, shell } from \"electron\";\nimport { IWindowManager } from \"@/types/main/window-manager\";\nimport fs from \"fs/promises\";\nimport { appUpdateSources } from \"@/common/constant\";\nimport axios from \"axios\";\nimport { compare } from \"compare-versions\";\n\nclass Utils {\n    private windowManager: IWindowManager;\n\n    public setup(windowManager: IWindowManager) {\n        this.windowManager = windowManager;\n\n        this.setupAppUtil();\n        this.setupWindowUtil();\n        this.setupShellUtil();\n        this.setupDialogUtil();\n    }\n\n\n    private setupAppUtil() {\n        ipcMain.on(\"@shared/utils/exit-app\", () => {\n            app.exit(0);\n        });\n\n        ipcMain.handle(\"@shared/utils/app-get-path\", (_, pathName) => {\n            return app.getPath(pathName);\n        });\n\n        ipcMain.handle(\"@shared/utils/check-update\", async () => {\n            const currentVersion = app.getVersion();\n            const updateInfo: ICommon.IUpdateInfo = {\n                version: currentVersion,\n            };\n            for (let i = 0; i < appUpdateSources.length; ++i) {\n                try {\n                    const rawInfo = (await axios.get(appUpdateSources[i])).data;\n                    if (compare(rawInfo.version, currentVersion, \">\")) {\n                        updateInfo.update = rawInfo;\n                        return updateInfo;\n                    }\n                } catch {\n                    // pass\n                }\n            }\n            return updateInfo;\n        });\n\n        ipcMain.on(\"@shared/utils/clear-cache\", () => {\n            const mainWindow = this.windowManager.mainWindow;\n            if (mainWindow) {\n                mainWindow.webContents.session.clearCache?.();\n            }\n        });\n\n        ipcMain.handle(\"@shared/utils/get-cache-size\", async () => {\n            const mainWindow = this.windowManager.mainWindow;\n            if (mainWindow) {\n                return mainWindow.webContents.session.getCacheSize?.();\n            }\n            return NaN;\n        });\n    }\n\n    private setupWindowUtil() {\n        ipcMain.on(\"@shared/utils/min-main-window\", (_, { skipTaskBar }) => {\n            const mainWindow = this.windowManager.mainWindow;\n            if (mainWindow) {\n                if (skipTaskBar) {\n                    mainWindow.hide();\n                    mainWindow.setSkipTaskbar(true);\n                } else {\n                    mainWindow.minimize();\n                }\n            }\n        });\n\n        ipcMain.on(\"@shared/utils/show-main-window\", () => {\n            this.windowManager.showMainWindow();\n        });\n\n        ipcMain.on(\"@shared/utils/set-lyric-window\", (_, enabled) => {\n            if (enabled) {\n                this.windowManager.showLyricWindow();\n            } else {\n                this.windowManager.closeLyricWindow();\n            }\n        });\n\n        ipcMain.on(\"@shared/utils/set-minimode-window\", (_, enabled) => {\n            if (enabled) {\n                this.windowManager.showMiniModeWindow();\n            } else {\n                this.windowManager.closeMiniModeWindow();\n            }\n        });\n\n\n        ipcMain.on(\"@shared/utils/ignore-mouse-event\", (evt, ignore) => {\n            const targetWindow = BrowserWindow.fromWebContents(evt.sender);\n            if (!targetWindow) {\n                return;\n            }\n            targetWindow.setIgnoreMouseEvents(ignore, {\n                forward: true,\n            });\n        });\n\n        ipcMain.on(\"@shared/utils/toggle-maximize-main-window\", () => {\n            const mainWindow = this.windowManager.mainWindow;\n\n            if (mainWindow) {\n                if (mainWindow.isMaximized()) {\n                    mainWindow.unmaximize();\n                } else {\n                    mainWindow.maximize();\n                }\n            }\n        });\n\n        ipcMain.on(\"@shared/utils/toggle-main-window-visible\", () => {\n            const mainWindow = this.windowManager.mainWindow;\n\n            if (mainWindow.isMinimized() || !mainWindow.isVisible()) {\n                mainWindow.show();\n            } else {\n                mainWindow.hide();\n                mainWindow.setSkipTaskbar(true);\n            }\n        });\n\n    }\n\n    private setupShellUtil() {\n        ipcMain.on(\"@shared/utils/open-url\", (_, url) => {\n            shell.openExternal(url);\n        });\n\n        ipcMain.on(\"@shared/utils/open-path\", (_, path) => {\n            shell.openPath(path);\n        });\n\n        ipcMain.handle(\"@shared/utils/show-item-in-folder\", async (_, path) => {\n            try {\n                await fs.stat(path);\n                shell.showItemInFolder(path);\n                return true;\n            } catch {\n                return false;\n            }\n        });\n    }\n\n    private setupDialogUtil() {\n        ipcMain.handle(\"@shared/utils/show-open-dialog\", async (_, options) => {\n            const mainWindow = this.windowManager.mainWindow;\n            if (!mainWindow) {\n                throw new Error(\"Invalid Window\");\n            }\n            return dialog.showOpenDialog(options);\n        });\n\n        ipcMain.handle(\"@shared/utils/show-save-dialog\", async (_, options) => {\n            const mainWindow = this.windowManager.mainWindow;\n            if (!mainWindow) {\n                throw new Error(\"Invalid Window\");\n            }\n            return dialog.showSaveDialog(options);\n        });\n    }\n\n}\n\n\nexport default new Utils();\n"
  },
  {
    "path": "src/shared/utils/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\nimport fs from \"fs/promises\";\nimport { rimraf } from \"rimraf\";\nimport url from \"url\";\n\n\n/****** fs utils ******/\nconst originalFsWriteFile = fs.writeFile;\nconst originalFsReadFile = fs.readFile;\n\nfunction writeFile(...args: Parameters<typeof originalFsWriteFile>): ReturnType<typeof originalFsWriteFile> {\n    return originalFsWriteFile(...args);\n}\n\nfunction readFile(...args: Parameters<typeof originalFsReadFile>): ReturnType<typeof originalFsReadFile> {\n    return originalFsReadFile(...args);\n}\n\nasync function isFile(path: string) {\n    try {\n        const stat = await fs.stat(path);\n        return stat.isFile();\n    } catch {\n        return false;\n    }\n}\n\nasync function isFolder(path: string) {\n    try {\n        const stat = await fs.stat(path);\n        return stat.isDirectory();\n    } catch {\n        return false;\n    }\n}\n\nfunction addFileScheme(filePath: string) {\n    return filePath.startsWith(\"file:\")\n        ? filePath\n        : url.pathToFileURL(filePath).toString();\n}\n\nconst fsUtil = {\n    writeFile,\n    readFile,\n    isFile,\n    isFolder,\n    rimraf,\n    addFileScheme,\n};\n\n/****** app utils *****/\nfunction exitApp() {\n    ipcRenderer.send(\"@shared/utils/exit-app\");\n}\n\nasync function getPath(pathName: \"home\" | \"appData\" | \"userData\" | \"sessionData\" | \"temp\" | \"exe\" | \"module\" | \"desktop\" | \"documents\" | \"downloads\" | \"music\" | \"pictures\" | \"videos\" | \"recent\" | \"logs\" | \"crashDumps\") {\n    return await ipcRenderer.invoke(\"@shared/utils/app-get-path\", pathName);\n}\n\nasync function checkUpdate() {\n    return await ipcRenderer.invoke(\"@shared/utils/check-update\");\n}\n\nasync function getCacheSize() {\n    return await ipcRenderer.invoke(\"@shared/utils/get-cache-size\");\n}\n\nasync function clearCache() {\n    ipcRenderer.send(\"@shared/utils/clear-cache\");\n}\n\nconst app = {\n    exitApp,\n    getPath,\n    checkUpdate,\n    getCacheSize,\n    clearCache,\n};\n\n\n/****** window utils *****/\nfunction minMainWindow(skipTaskBar: boolean) {\n    ipcRenderer.send(\"@shared/utils/min-main-window\", { skipTaskBar });\n}\n\nfunction showMainWindow() {\n    ipcRenderer.send(\"@shared/utils/show-main-window\");\n}\n\nfunction setLyricWindow(enabled: boolean) {\n    ipcRenderer.send(\"@shared/utils/set-lyric-window\", enabled);\n}\n\nfunction setMinimodeWindow(enabled: boolean) {\n    ipcRenderer.send(\"@shared/utils/set-minimode-window\", enabled);\n}\n\nfunction ignoreMouseEvent(ignore: boolean) {\n    ipcRenderer.send(\"@shared/utils/ignore-mouse-event\", ignore);\n}\n\nfunction toggleMainWindowVisible() {\n    ipcRenderer.send(\"@shared/utils/toggle-main-window-visible\");\n}\n\nfunction toggleMainWindowMaximize() {\n    ipcRenderer.send(\"@shared/utils/toggle-maximize-main-window\");\n}\n\nconst appWindow = {\n    minMainWindow,\n    showMainWindow,\n    setLyricWindow,\n    setMinimodeWindow,\n    ignoreMouseEvent,\n    toggleMainWindowVisible,\n    toggleMainWindowMaximize,\n};\n\n/****** shell utils *****/\nfunction openExternal(url: string) {\n    ipcRenderer.send(\"@shared/utils/open-url\", url);\n}\n\nfunction openPath(path: string) {\n    ipcRenderer.send(\"@shared/utils/open-path\", path);\n}\n\nasync function showItemInFolder(path: string): Promise<boolean> {\n    return await ipcRenderer.invoke(\"@shared/utils/show-item-in-folder\", path);\n}\n\nconst shell = {\n    openExternal,\n    openPath,\n    showItemInFolder,\n};\n\n/****** dialog utils *****/\nfunction showOpenDialog(options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> {\n    return ipcRenderer.invoke(\"@shared/utils/show-open-dialog\", options);\n}\n\nfunction showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> {\n    return ipcRenderer.invoke(\"@shared/utils/show-save-dialog\", options);\n}\n\nconst dialog = {\n    showOpenDialog,\n    showSaveDialog,\n};\n\n\nconst mod = {\n    fs: fsUtil,\n    app,\n    appWindow,\n    shell,\n    dialog,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/utils\", mod);\n\n"
  },
  {
    "path": "src/shared/utils/renderer.ts",
    "content": "import type fs from \"fs/promises\";\nimport type rimraf from \"rimraf\";\n\ninterface IMod {\n    fs: {\n        writeFile(...args: Parameters<typeof fs.writeFile>): ReturnType<typeof fs.writeFile>;\n        readFile(...args: Parameters<typeof fs.readFile>): ReturnType<typeof fs.readFile>;\n        isFile: (path: string) => Promise<boolean>;\n        isFolder: (path: string) => Promise<boolean>;\n        rimraf: typeof rimraf.rimraf;\n        addFileScheme: (filePath: string) => string;\n    },\n    app: {\n        exitApp: () => void;\n        getPath: (pathName: \"home\" | \"appData\" | \"userData\" | \"sessionData\" | \"temp\" | \"exe\" | \"module\" | \"desktop\" | \"documents\" | \"downloads\" | \"music\" | \"pictures\" | \"videos\" | \"recent\" | \"logs\" | \"crashDumps\") => Promise<string>;\n        checkUpdate: () => Promise<ICommon.IUpdateInfo>;\n        clearCache: () => void;\n        getCacheSize: () => Promise<number>;\n    }\n    appWindow: {\n        minMainWindow: (skipTaskBar?: boolean) => void;\n        showMainWindow: () => void;\n        setLyricWindow: (enabled: boolean) => void;\n        setMinimodeWindow: (enabled: boolean) => void;\n        setLyricWindowLock: (lockState: boolean) => void;\n        ignoreMouseEvent: (ignore: boolean) => void;\n        toggleMainWindowVisible: () => void;\n        toggleMainWindowMaximize: () => void;\n    },\n    shell: {\n        openExternal: (url: string) => void;\n        openPath: (path: string) => void;\n        showItemInFolder: (path: string) => Promise<boolean>;\n    },\n    dialog: {\n        showOpenDialog(options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue>;\n        showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;\n    }\n\n}\n\nconst utils = window[\"@shared/utils\" as any] as unknown as IMod;\n\n\nexport default utils;\nexport const { fs: fsUtil, app: appUtil, appWindow: appWindowUtil, shell: shellUtil, dialog: dialogUtil } = utils;\n"
  },
  {
    "path": "src/shared/window-drag/main.ts",
    "content": "/**\n * https://github.com/electron/electron/issues/1354#issuecomment-1356330873\n */\n\nimport { BrowserWindow, ipcMain } from \"electron\";\nimport * as process from \"node:process\";\nimport debounce from \"@/common/debounce\";\n\nconst WM_MOUSEMOVE = 0x0200; // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousemove\nconst WM_LBUTTONUP = 0x0202; // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-lbuttonup\n\nconst MK_LBUTTON = 0x0001;\n\ninterface IDragOptions {\n    width: number;\n    height: number;\n    getWindowSize?: () => ICommon.ISize;\n    onDragEnd: (position: ICommon.IPoint | null) => void;\n}\n\nconst makeWin32WindowFullyDraggable = (\n    browserWindow: BrowserWindow,\n    options: IDragOptions,\n): void => {\n    const { height, width, getWindowSize, onDragEnd } = options;\n    const initialPos = {\n        x: 0,\n        y: 0,\n        height,\n        width,\n    };\n\n    let dragging = false;\n    let cachePosition: ICommon.IPoint | null = null;\n\n    browserWindow.hookWindowMessage(WM_LBUTTONUP, () => {\n        dragging = false;\n        if (cachePosition !== null) {\n            onDragEnd(cachePosition);\n        }\n        cachePosition = null;\n    });\n    browserWindow.hookWindowMessage(\n        WM_MOUSEMOVE,\n        (wParam: Buffer, lParam: Buffer) => {\n            if (!browserWindow) {\n                return;\n            }\n\n            const wParamNumber: number = wParam.readInt16LE(0);\n\n            if (!(wParamNumber & MK_LBUTTON)) {\n                // <-- checking if left mouse button is pressed\n                return;\n            }\n\n            const x = lParam.readInt16LE(0);\n            const y = lParam.readInt16LE(2);\n            if (!dragging) {\n                dragging = true;\n\n                let initWidth = width;\n                let initHeight = height;\n                if (getWindowSize) {\n                    const size = getWindowSize();\n                    initWidth = size.width;\n                    initHeight = size.height;\n                }\n\n                initialPos.x = x;\n                initialPos.y = y;\n                initialPos.height = initHeight;\n                initialPos.width = initWidth;\n                return;\n            }\n\n            cachePosition = {\n                x: x + browserWindow.getPosition()[0] - initialPos.x,\n                y: y + browserWindow.getPosition()[1] - initialPos.y,\n            };\n            browserWindow.setBounds({\n                x: cachePosition.x,\n                y: cachePosition.y,\n                height: initialPos.height,\n                width: initialPos.width,\n            });\n        },\n    );\n};\n\n\nclass WindowDrag {\n    private registeredWindows = new Map<BrowserWindow, IDragOptions>();\n\n    setup(): void {\n        ipcMain.on(\"set-window-draggable\", (_evt, position) => {\n            const window = BrowserWindow.fromWebContents(_evt.sender);\n            if (this.registeredWindows.has(window)) {\n                const metadata = this.registeredWindows.get(window);\n                let width = metadata.width;\n                let height = metadata.height;\n                if (metadata.getWindowSize) {\n                    const size = metadata.getWindowSize();\n                    width = size.width;\n                    height = size.height;\n                }\n                window.setBounds({\n                    x: position.x,\n                    y: position.y,\n                    height: height,\n                    width: width,\n                });\n                metadata.onDragEnd?.(position);\n            }\n        });\n    }\n\n    setWindowDraggable(window: BrowserWindow, options: IDragOptions): void {\n        if (process.platform === \"win32\") {\n            makeWin32WindowFullyDraggable(window, options);\n        } else {\n            const originalDragEnd = options.onDragEnd;\n            options.onDragEnd = debounce((position: ICommon.IPoint | null) => {\n                originalDragEnd?.(position);\n            }, 300, {\n                leading: false,\n                trailing: true,\n            });\n            this.registeredWindows.set(window, options);\n            window.on(\"closed\", () => {\n                this.registeredWindows.delete(window);\n            });\n        }\n\n    }\n}\n\nexport default new WindowDrag();\n"
  },
  {
    "path": "src/shared/window-drag/preload.ts",
    "content": "import { contextBridge, ipcRenderer } from \"electron\";\n\nfunction dragWindow(position: ICommon.IPoint) {\n    ipcRenderer.send(\"set-window-draggable\", position);\n}\n\nconst mod = {\n    dragWindow,\n};\n\ncontextBridge.exposeInMainWorld(\"@shared/window-drag\", mod);\n\n"
  },
  {
    "path": "src/shared/window-drag/renderer.ts",
    "content": "import { getGlobalContext } from \"@shared/global-context/renderer\";\n\n\ninterface IMod {\n    dragWindow(position: ICommon.IPoint): void;\n}\n\nconst mod = window[\"@shared/window-drag\" as any] as unknown as IMod;\n\nlet startClientPos: ICommon.IPoint | null = null;\nlet isMoving = false;\nlet injected = false;\n\n\nfunction injectHandler() {\n    const task = () => setTimeout(() => {\n        if (injected) {\n            return;\n        }\n        injected = true;\n\n        if (getGlobalContext().platform !== \"win32\") {\n            // win32使用make-window-fully-draggable方案\n            window.addEventListener(\"mousedown\", (e) => {\n                startClientPos = {\n                    x: e.clientX,\n                    y: e.clientY,\n                };\n                isMoving = true;\n            });\n            window.addEventListener(\"mousemove\", (e) => {\n                if (startClientPos && isMoving) {\n                    mod.dragWindow({\n                        x: e.screenX - startClientPos.x,\n                        y: e.screenY - startClientPos.y,\n                    });\n                }\n            });\n\n            window.addEventListener(\"mouseup\", () => {\n                isMoving = false;\n                startClientPos = null;\n            });\n        }\n    });\n\n    if (document.readyState === \"complete\") {\n        task();\n    } else {\n        document.onload = task;\n    }\n}\n\nconst WindowDrag = {\n    injectHandler,\n};\n\nexport default WindowDrag;\n"
  },
  {
    "path": "src/types/app-config.d.ts",
    "content": "interface _IAppConfig {\n    \"$schema-version\": number;\n    \"normal.closeBehavior\": \"exit_app\" | \"minimize\";\n    \"normal.maxHistoryLength\": number;\n    \"normal.checkUpdate\": boolean;\n    \"normal.autoLoadMore\": boolean;\n    \"normal.taskbarThumb\": \"window\" | \"artwork\";\n    \"normal.musicListColumnsShown\": Array<\"duration\" | \"platform\">;\n    \"normal.language\": string;\n\n    /** 歌单内搜索区分大小写 */\n    \"playMusic.caseSensitiveInSearch\": boolean;\n    /** 默认播放音质 */\n    \"playMusic.defaultQuality\": IMusic.IQualityKey;\n    /** 默认播放音质缺失时 */\n    \"playMusic.whenQualityMissing\": \"higher\" | \"lower\" | \"skip\";\n    /** 双击音乐列表时 */\n    \"playMusic.clickMusicList\": \"normal\" | \"replace\";\n    /** 播放失败时 */\n    \"playMusic.playError\": \"pause\" | \"skip\";\n    /** 输出设备 */\n    \"playMusic.audioOutputDevice\": MediaDeviceInfo | null;\n    /** 设备变化时 */\n    \"playMusic.whenDeviceRemoved\": \"pause\" | \"play\";\n\n    /** [darwin only] 显示状态栏歌词 */\n    \"lyric.enableStatusBarLyric\": boolean;\n    /** 显示桌面歌词 */\n    \"lyric.enableDesktopLyric\": boolean;\n    /** 桌面歌词置顶 */\n    \"lyric.alwaysOnTop\": boolean;\n    /** 锁定桌面歌词 */\n    \"lyric.lockLyric\": boolean;\n    /** 字体 */\n    \"lyric.fontData\": FontData;\n    /** 字体颜色 */\n    \"lyric.fontColor\": string;\n    /** 字体大小 */\n    \"lyric.fontSize\": number;\n    /** 描边颜色 */\n    \"lyric.strokeColor\": string;\n\n    /** 是否启用本地快捷键 */\n    \"shortCut.enableLocal\": boolean;\n    /** 是否启用全局快捷键 */\n    \"shortCut.enableGlobal\": boolean;\n    /** 快捷键映射 */\n    \"shortCut.shortcuts\": Record<\n        | \"play/pause\"\n        | \"skip-previous\"\n        | \"skip-next\"\n        | \"toggle-desktop-lyric\"\n        | \"volume-up\"\n        | \"volume-down\"\n        | \"like/dislike\",\n        | \"toggle-main-window-visible\",\n        {\n            local?: string[] | null;\n            global?: string[] | null;\n        }\n    >;\n\n    /** 下载路径 */\n    \"download.path\": string;\n    /** 默认下载音质 */\n    \"download.defaultQuality\": IMusic.IQualityKey;\n    /** 默认下载音质缺失时 */\n    \"download.whenQualityMissing\": \"higher\" | \"lower\";\n    /** 最多同时下载 */\n    \"download.concurrency\": number;\n\n    /** 是否自动升级插件 */\n    \"plugin.autoUpdatePlugin\": boolean;\n    /** 是否不检测插件版本 */\n    \"plugin.notCheckPluginVersion\": boolean;\n\n    /** 是否启用代理 */\n    \"network.proxy.enabled\": boolean;\n    \"network.proxy.host\": string;\n    \"network.proxy.port\": string;\n    \"network.proxy.username\": string;\n    \"network.proxy.password\": string;\n\n    /** 恢复歌单时行为 */\n    \"backup.resumeBehavior\": \"append\" | \"overwrite\";\n    /** URL */\n    \"backup.webdav.url\": string;\n    /** 用户名 */\n    \"backup.webdav.username\": string;\n    /** 密码 */\n    \"backup.webdav.password\": string;\n\n    /** 本地音乐配置 */\n    \"localMusic.watchDir\": string[];\n\n    /** 不需要用户配置的数据 */\n    \"private.mainWindowSize\": ICommon.ISize;\n    \"private.lyricWindowPosition\": ICommon.IPoint;\n    \"private.lyricWindowSize\": ICommon.ISize;\n\n    \"private.minimodeWindowPosition\": ICommon.IPoint;\n\n    \"private.pluginMeta\": Record<string, IPlugin.IPluginMeta>;\n\n    \"private.minimode\": boolean;\n\n}\n\ntype PartialOrNull<T> = { [P in keyof T]?: T[P] | null };\nexport type IAppConfig = PartialOrNull<_IAppConfig>;\nexport type IAppConfigKey = keyof IAppConfig;\n"
  },
  {
    "path": "src/types/assets.d.ts",
    "content": "type Styles = Record<string, string>;\n\ndeclare module \"*.svg\" {\n  export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;\n\n  const content: string;\n  export default content;\n}\n\ndeclare module \"*.png\" {\n  const content: string;\n  export default content;\n}\n\ndeclare module \"*.jpg\" {\n  const content: string;\n  export default content;\n}\n\ndeclare module \"*.ico\" {\n  const content: string;\n  export default content;\n}\n"
  },
  {
    "path": "src/types/audio-controller.d.ts",
    "content": "import { PlayerState } from \"@/common/constant\";\nimport { CurrentTime, ErrorReason } from \"@renderer/core/track-player/enum\";\n\nexport interface IAudioController {\n    // 是否有音源\n    hasSource: boolean;\n\n    playerState: PlayerState;\n\n    musicItem: IMusic.IMusicItem | null;\n\n    // 准备音乐信息\n    prepareTrack?(musicItem: IMusic.IMusicItem): void;\n\n    // 设置音源\n    setTrackSource(trackSource: IMusic.IMusicSource, musicItem: IMusic.IMusicItem): void;\n\n    // 暂停\n    pause(): void;\n\n    // 播放\n    play(): void;\n\n    // 设置音量\n    setVolume(volume: number): void;\n\n    // 跳转\n    seekTo(seconds: number): void;\n\n    // 设置循环\n    setLoop(isLoop: boolean): void;\n\n    // 设置播放速度\n    setSpeed(speed: number): void;\n\n    // 设置输出设备id\n    setSinkId(deviceId: string): Promise<void>;\n\n    // 清空当前播放的歌曲\n    reset(): void;\n\n    // 销毁audio实例\n    destroy(): void;\n\n    onPlayerStateChanged?: (playerState: PlayerState) => void;\n    // 进度更新\n    onProgressUpdate?: (progress: CurrentTime) => void;\n    // 出错\n    onError?: (type: ErrorReason, error?: any) => void;\n    // 播放结束\n    onEnded?: () => void;\n    // 音量改变\n    onVolumeChange?: (volume: number) => void;\n    // 速度改变\n    onSpeedChange?: (speed: number) => void;\n\n}\n\n"
  },
  {
    "path": "src/types/common.d.ts",
    "content": "declare namespace ICommon {\n  export type WithMusicList<T> = T & {\n    musicList?: IMusic.IMusicItem[];\n  };\n\n  export type PaginationResponse<T> = {\n    isEnd?: boolean;\n    data?: T[];\n  };\n\n  interface IUpdateInfo {\n    version: string;\n    update?: {\n      version: string;\n      changeLog: string[];\n      download: string[];\n    };\n  }\n\n  interface IPoint {\n    x: number;\n    y: number;\n  }\n\n  interface ISize {\n    width: number;\n    height: number;\n  }\n\n  interface IThemePack {\n    id?: string;\n    /** 主题 */\n    name: string;\n    /** 加载之后的路径，内部属性 */\n    hash?: string;\n    path: string;\n    /** 缩略图 */\n    thumb?: string;\n    /** 预览图 */\n    preview: string;\n    /** 主题更新链接 */\n    srcUrl?: string;\n    /** 主题作者 */\n    author?: string;\n    /** 版本号 */\n    version?: string;\n    description?: string;\n    iframe?: Record<\n      \"app\" | \"header\" | \"body\" | \"music-bar\" | \"side-bar\" | \"page\",\n      string\n    >;\n  }\n\n  interface IDownloadFileSize {\n    /** 当前下载的大小 */\n    currentSize?: number;\n    /** 总大小 */\n    totalSize?: number;\n  }\n\n  type ICommonReturnType = [\n    boolean,\n    {\n      msg?: string;\n      [k: string]: any;\n    }?\n  ];\n\n  interface ICommand {\n    SetPlayerState: PlayerState;\n    SkipToPrevious: void;\n    SkipToNext: void;\n    SetRepeatMode: RepeatMode;\n    PlayMusic: IMusic.IMusicItem;\n  }\n\n  type ICommandKey = keyof ICommand;\n}\n"
  },
  {
    "path": "src/types/main/window-manager.d.ts",
    "content": "import { BrowserWindow } from \"electron\";\n\nexport type IWindowNames = \"main\" | \"lyric\" | \"minimode\";\n\nexport interface IWindowEvents {\n    \"WindowCreated\": {\n        windowName: IWindowNames;\n        browserWindow: BrowserWindow;\n    }\n}\n\nexport interface IWindowManager {\n\n    mainWindow: BrowserWindow | null;\n    lyricWindow: BrowserWindow | null;\n    miniModeWindow: BrowserWindow | null;\n\n    /**\n     * 获取主窗口的引用\n     */\n    getMainWindow(): BrowserWindow;\n\n    /**\n     * 获取所有扩展窗口的引用\n     */\n    getExtensionWindows(): BrowserWindow[];\n\n    /**\n     * 获取所有窗口的引用\n     */\n    getAllWindows(): BrowserWindow[];\n\n    /**\n     * 为特定事件类型注册监听器\n     */\n    on<T extends keyof IWindowEvents>(event: T, listener: (data: IWindowEvents[T]) => void): void;\n\n\n    /**\n     * 显示主窗口\n     */\n    showMainWindow(): void;\n\n    /**\n     * 关闭主窗口\n     */\n    closeMainWindow(): void;\n\n\n    /**\n     * 显示歌词窗口\n     */\n    showLyricWindow(): void;\n\n    /**\n     * 关闭歌词窗口\n     */\n    closeLyricWindow(): void;\n\n\n    /**\n     * 显示迷你模式窗口\n     */\n    showMiniModeWindow(): void;\n\n    /**\n     * 关闭迷你模式窗口\n     */\n    closeMiniModeWindow(): void;\n}\n"
  },
  {
    "path": "src/types/media.d.ts",
    "content": "declare namespace IMedia {\n  export type SupportMediaItem = {\n    music: IMusic.IMusicItem;\n    album: IAlbum.IAlbumItem;\n    artist: IArtist.IArtistItem;\n    sheet: IMusic.IMusicSheetItem;\n    lyric: ILyric.ILyricItem;\n  };\n\n  export type SupportMediaType = keyof SupportMediaItem;\n\n  interface IUnique {\n    /** 唯一id */\n    id: string;\n    $?: any;\n    [k: string | number | symbol]: any;\n  }\n\n  /** 基础媒体类型 */\n  interface IMediaBase extends IUnique {\n    /** 媒体来源平台，如本地等 */\n    platform: string;\n    [k: string | number | symbol]: any;\n  }\n}\n\ndeclare namespace IMusic {\n  interface IMusicSource {\n    /** 播放的http请求头 */\n    headers?: Record<string, string>;\n    /** 兜底播放 */\n    url?: string;\n    /** UA */\n    userAgent?: string;\n  }\n\n  interface IMusicItem extends IMedia.IMediaBase {\n    /** 作者 */\n    artist: string;\n    /** 歌曲标题 */\n    title: string;\n    /** 时长(s) */\n    duration?: number;\n    /** 专辑名 */\n    album?: string;\n    /** 专辑封面图 */\n    artwork?: string;\n    /** 默认音源 */\n    url?: string;\n    // todo: 格式化\n    /** 歌词URL */\n    lrc?: string;\n    /** 歌词文本 */\n    rawLrc?: string;\n    // 其他\n    [k: string | number | symbol]: any;\n  }\n\n  // 音乐的内部数据\n  interface IMusicItemInternalData {\n    downloadData?: {\n      path: string;\n      quality: IQualityKey;\n    };\n  }\n\n  interface IMusicSheetItem extends IMedia.IMediaBase {\n    /** 封面图 */\n    artwork?: string;\n    /** 标题 */\n    title: string;\n    /** 描述 */\n    description?: string;\n    /** 作品总数 */\n    worksNum?: number;\n    /** 播放次数 */\n    playCount?: number;\n    /** 播放列表 */\n    musicList?: IMusicItem[];\n    /** 歌单创建日期 */\n    createAt?: number;\n    // 歌单作者\n    artist?: string;\n  }\n\n  /** 数据库中存储的歌单列表，其中音乐列表只存id */\n  interface IDBMusicSheetItem extends IMusicSheetItem {\n    musicList?: IMedia.IMediaBase[];\n  }\n\n  interface ILocalMusicList {\n    folder: string;\n    musicList: IMusic.IMusicItem[];\n  }\n\n  /** 歌单集合 */\n  export interface IMusicSheetGroupItem {\n    title?: string;\n    data: Array<IMusicSheetItem>;\n  }\n\n  // 音质\n  export type IQualityKey = \"low\" | \"standard\" | \"high\" | \"super\";\n\n  type IMusicItemPartial = Partial<IMusicItem>;\n}\n\ndeclare namespace IAlbum {\n  interface IAlbumItem extends IMusic.IMusicSheetItem {\n    artwork?: string;\n    title: string;\n    date?: string;\n    artist?: string;\n    description: string;\n    /** 专辑内有多少作品 */\n    worksNum?: number;\n    musicList?: IMusic.IMusicItem[];\n  }\n}\n\ndeclare namespace IArtist {\n  interface IArtistItem {\n    name: string;\n    id: string;\n    fans?: number;\n    description?: string;\n    platform: string;\n    avatar: string;\n    musicList?: IMusic.IMusicItem[];\n    albumList?: IAlbum.IAlbumItem[];\n  }\n\n  type ArtistMediaType = \"music\" | \"album\";\n}\n\ndeclare namespace ILyric {\n  interface ILyricItem extends IMusic.IMusicItem {\n    /** 歌词（无时间戳） */\n    rawLrcTxt?: string;\n  }\n\n  interface ILyricSource {\n    lrc?: string;\n    rawLrc?: string;\n    translation?: string;\n  }\n}\n\ndeclare namespace IComment {\n  interface ICommentItem {\n    id?: string;\n    // 用户名\n    nickName: string;\n    // 头像\n    avatar?: string;\n    // 评论内容\n    comment: string;\n    // 点赞数\n    like?: number;\n    // 评论时间\n    createAt?: number;\n    // 地址\n    location?: string;\n  }\n\n  interface IComment extends ICommentItem {\n    // 回复\n    replies?: IComment[];\n  }\n}\n"
  },
  {
    "path": "src/types/model.d.ts",
    "content": "// 数据模型\ndeclare namespace IDataBaseModel {\n    export interface IMusicSheetModel {\n        platform: string;\n        id: string;\n        /** 标题 */\n        title: string;\n        /** 封面图 */\n        artwork?: string;\n        /** 描述 */\n        description?: string;\n        /** 作品总数 */\n        worksNum?: number;\n        /** 播放次数 */\n        playCount?: number;\n        /** 歌单创建日期 */\n        createAt?: number;\n        // 歌单作者\n        artist?: string;\n        // 原始数据\n        _raw: string;\n        // 排序信息\n        _sortIndex: number;\n    }\n\n\n    export interface IMusicItemModel {\n        platform: string;\n        id: string;\n        /** 作者 */\n        artist?: string;\n        /** 歌曲标题 */\n        title: string;\n        /** 时长(s) */\n        duration?: number;\n        /** 专辑名 */\n        album?: string;\n        /** 专辑封面图 */\n        artwork?: string;\n        /** 添加到歌单的时间 */\n        _timestamp: number;\n        // 完整信息\n        _raw: string;\n        // 在歌单内的顺序\n        _sortIndex: number;\n        // 歌单ID\n        _musicSheetId: string;\n        // 歌单\n        _musicSheetPlatform: string;\n    }\n}"
  },
  {
    "path": "src/types/plugin.d.ts",
    "content": "declare namespace IPlugin {\n  export interface IMediaSourceResult {\n    headers?: Record<string, string>;\n    /** 兜底播放 */\n    url?: string;\n    /** UA */\n    userAgent?: string;\n    /** 音质 */\n    quality?: IMusic.IQualityKey;\n  }\n\n  export interface ISearchResult<T extends IMedia.SupportMediaType> {\n    isEnd?: boolean;\n    data: IMedia.SupportMediaItem[T][];\n  }\n\n  export type ISearchResultType = IMedia.SupportMediaType;\n\n  type ISearchFunc = <T extends IMedia.SupportMediaType>(\n    query: string,\n    page: number,\n    type: T\n  ) => Promise<ISearchResult<T>>;\n\n  type IGetArtistWorksFunc = <T extends IArtist.ArtistMediaType>(\n    artistItem: IArtist.IArtistItem,\n    page: number,\n    type: T\n  ) => Promise<ISearchResult<T>>;\n\n  interface IUserVariable {\n    /** 变量键名 */\n    key: string;\n    /** 变量名 */\n    name?: string;\n    /** 提示文案 */\n    hint?: string;\n  }\n\n  interface IAlbumInfoResult {\n    isEnd?: boolean;\n    albumItem?: IAlbum.IAlbumItem;\n    musicList?: IMusic.IMusicItem[];\n  }\n\n  interface ISheetInfoResult {\n    isEnd?: boolean;\n    sheetItem?: IMusic.IMusicSheetItem;\n    musicList?: IMusic.IMusicItem[];\n  }\n\n  interface ITopListInfoResult {\n    isEnd?: boolean;\n    topListItem?: IMusic.IMusicSheetItem;\n    musicList?: IMusic.IMusicItem[];\n  }\n\n  interface IGetRecommendSheetTagsResult {\n    // 固定的tag\n    pinned?: IMusic.IMusicSheetItem[];\n    data?: IMusic.IMusicSheetGroupItem[];\n  }\n\n  interface IGetCommentResult {\n    isEnd?: boolean;\n    data?: IComment.IComment[];\n  }\n\n  interface IPluginDefine {\n    /** 来源名 */\n    platform: string;\n    /** 匹配的版本号 */\n    appVersion?: string;\n    /** 插件版本 */\n    version?: string;\n    /** 远程更新的url */\n    srcUrl?: string;\n    /** 主键，会被存储到mediameta中 */\n    primaryKey?: string[];\n    /** 默认搜索类型 */\n    defaultSearchType?: IMedia.SupportMediaType;\n    /** 有效搜索类型 */\n    supportedSearchType?: ICommon.SupportMediaType[];\n    /** 插件缓存控制 */\n    cacheControl?: \"cache\" | \"no-cache\" | \"no-store\";\n    /** 插件作者 */\n    author?: string;\n    /** 用户自定义输入 */\n    userVariables?: IUserVariable[];\n    /** 提示文本 */\n    hints?: Record<string, string[]>;\n    /** 搜索 */\n    search?: ISearchFunc;\n    /** 获取根据音乐信息获取url */\n    getMediaSource?: (\n      musicItem: IMusic.IMusicItemPartial,\n      quality: IMusic.IQualityKey\n    ) => Promise<IMediaSourceResult | null>;\n    /** 根据主键去查询歌曲信息 */\n    getMusicInfo?: (\n      musicBase: IMedia.IMediaBase\n    ) => Promise<Partial<IMusic.IMusicItem> | null>;\n    /** 获取歌词 */\n    getLyric?: (\n      musicItem: IMusic.IMusicItemPartial\n    ) => Promise<ILyric.ILyricSource | null>;\n    /** 获取专辑信息，里面的歌曲分页 */\n    getAlbumInfo?: (\n      albumItem: IAlbum.IAlbumItem,\n      page: number\n    ) => Promise<IAlbumInfoResult | null>;\n    /** 获取歌单信息，有分页 */\n    getMusicSheetInfo?: (\n      sheetItem: IMusic.IMusicSheetItem,\n      page: number\n    ) => Promise<ISheetInfoResult | null>;\n    /** 获取作品，有分页 */\n    getArtistWorks?: IGetArtistWorksFunc;\n    /** 导入歌单 */\n    // todo: 数据结构应该是IMusicSheetItem\n    importMusicSheet?: (urlLike: string) => Promise<IMusic.IMusicItem[] | null>;\n    /** 导入单曲 */\n    importMusicItem?: (urlLike: string) => Promise<IMusic.IMusicItem | null>;\n    /** 获取榜单 */\n    getTopLists?: () => Promise<IMusic.IMusicSheetGroupItem[]>;\n    /** 获取榜单详情 */\n    getTopListDetail?: (\n      topListItem: IMusic.IMusicSheetItem,\n      page: number\n    ) => Promise<ITopListInfoResult>;\n    /** 获取热门歌单tag */\n    getRecommendSheetTags?: () => Promise<IGetRecommendSheetTagsResult>;\n    /** 歌单列表 */\n    getRecommendSheetsByTag?: (\n      tag: ICommon.IUnique,\n      page?: number\n    ) => Promise<ICommon.PaginationResponse<IMusic.IMusicSheetItem>>;\n    /** 歌曲评论 */\n    getMusicComments?: (musicItem: IMusic.IMusicItem, page?: number) => Promise<IGetCommentResult>\n  }\n\n  export interface IPluginInstance extends IPluginDefine {\n    /** 内部属性 */\n    /** 插件路径 */\n    _path: string;\n  }\n\n  type R = Required<IPluginInstance>;\n  export type IPluginInstanceMethods = {\n    [K in keyof R as R[K] extends (...args: any) => any ? K : never]: R[K];\n  };\n\n  /** 插件其他属性 */\n  export type IPluginMeta = {\n    order?: number;\n    userVariables?: Record<string, string>;\n  };\n\n  export type IPluginDelegate = {\n    // 除去函数\n    [K in keyof R as R[K] extends (...args: any) => any ? never : K]: R[K];\n  } & {\n    supportedMethod: string[];\n    hash: string;\n    path: string;\n  };\n}\n"
  },
  {
    "path": "src/types/preload.d.ts",
    "content": "interface Window {\n  path: typeof import(\"node:path\");\n}\n"
  },
  {
    "path": "src/types/user-perference.d.ts",
    "content": "declare namespace IUserPreference {\n  interface IType {\n    /** 重复模式 */\n    repeatMode: string;\n    /** 当前进度 */\n    currentMusic: IMusic.IMusicItem;\n    currentProgress: number;\n    currentQuality: IMusic.IQualityKey;\n    /** 当前音量 */\n    volume: number;\n    /** 倍速 */\n    speed: number;\n    /** 订阅 */\n    subscription: Array<{\n      title?: string;\n      srcUrl: string;\n    }>;\n    skipVersion: string;\n    inlineLyricFontSize: string;\n    /** 展示翻译 */\n    showTranslation: boolean;\n  }\n\n  interface IDBType {\n    /** 当前播放队列 */\n    playList: IMusic.IMusicItem[];\n    /** 最近播放队列 */\n    recentlyPlayList: IMusic.IMusicItem[];\n    /** 已下载列表 */\n    downloadedList: IMedia.IMediaBase[];\n    /** 本地音乐监听列表 */\n    localWatchDir: string[];\n    /** 本地音乐勾选的监听列表 */\n    localWatchDirChecked: string[];\n    /** 收藏的歌单 */\n    starredMusicSheets: IMedia.IMediaBase[];\n    /** 搜索历史 */\n    searchHistory: string[];\n    /** 插件数据 */\n    pluginMeta: Record<string, IPlugin.IPluginMeta>;\n  }\n}\n"
  },
  {
    "path": "src/types/window.d.ts",
    "content": "/** 某些没有类型的新特性 */\ninterface Window {\n    /** 获取本地字体 */\n  queryLocalFonts: () => Promise<FontData[]>\n}\n\n\ndeclare interface FontData {\n    family: readonly string;\n    fullName: readonly string;\n    postscriptName: readonly string;\n    style: readonly string;\n}\n"
  },
  {
    "path": "src/webworkers/db-worker/const.ts",
    "content": "export const TableName_SheetMusicListPrefix = \"SHEET_MUSICLIST_\";\n/** 默认歌单的歌曲列表 */\nexport const TableName_DefaultSheetMusicList = \"SHEET_MUSICLIST_favorite\";\n/** 本地歌单的歌曲列表 */\nexport const TableName_LocalSheetMusicList = \"SHEET_MUSICLIST_local\";\n/** 本地歌单 */\nexport const TableName_LocalSheets = \"localMusicSheets\";\n"
  },
  {
    "path": "src/webworkers/db-worker/index.ts",
    "content": "import * as Comlink from \"comlink\";\nimport * as chokidar from \"chokidar\";\nimport path from \"path\";\nimport { supportLocalMediaType } from \"@/common/constant\";\nimport debounce from \"lodash.debounce\";\nimport { parseLocalMusicItem } from \"@/common/file-util\";\nimport { setInternalData } from \"@/common/media-util\";\nimport { safeParse } from \"@/common/safe-serialization\";\nimport Database from \"better-sqlite3\";\n\nlet database: Database.Database;\n\nfunction setupWorker(dbPath: string) {\n    database = new Database(dbPath);\n    database.pragma(\"journal_mode = WAL\");\n}\n\nComlink.expose({});\n"
  },
  {
    "path": "src/webworkers/db-worker/utils.ts",
    "content": "import type { Database } from \"better-sqlite3\";\n\nconst validTableNameRegex = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\nfunction checkTableName(tableName: string) {\n    if (!validTableNameRegex.test(tableName)) {\n        throw new Error(`Invalid table name: ${tableName}`);\n    }\n}\n\n/**\n *\n * @param database Database Instance\n * @param tableName tableName\n * @returns\n */\nexport function isTableExist(database: Database, tableName: string) {\n    return !!database\n        .prepare<string, { cnt: number }>(\n            \"SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name=?\",\n        )\n        .get(tableName).cnt;\n}\n\nexport function createMusicListTable(database: Database, tableName: string) {\n    checkTableName(tableName);\n\n    database\n        .prepare(\n            `CREATE TABLE IF NOT EXISTS \"main\".\"${tableName}\" (\n       \"platform\" TEXT NOT NULL,\n       \"id\" text NOT NULL,\n       \"title\" TEXT,\n       \"artist\" TEXT,\n       \"artwork\" TEXT,\n       \"url\" TEXT,\n       \"lrc\" TEXT,\n       \"album\" TEXT,\n       \"extra\" TEXT,\n       \"$sortIndex\" INTEGER NOT NULL DEFAULT 0,\n       \"$raw\" TEXT,\n       PRIMARY KEY (\"platform\", \"id\")\n    );`,\n        )\n        .run();\n}\n"
  },
  {
    "path": "src/webworkers/db-worker.ts",
    "content": "import * as Comlink from \"comlink\";\nimport * as chokidar from \"chokidar\";\nimport path from \"path\";\nimport { supportLocalMediaType } from \"@/common/constant\";\nimport debounce from \"lodash.debounce\";\nimport { parseLocalMusicItem } from \"@/common/file-util\";\nimport { setInternalData } from \"@/common/media-util\";\nimport { safeParse } from \"@/common/safe-serialization\";\nimport Database from \"better-sqlite3\";\n\nconst dbPath = \"\";\nconst database = new Database(dbPath);\ndatabase.pragma(\"journal_mode = WAL\");\n\nfunction getSheetItem(sheetId: string): IMusic.IMusicSheetItem | null {\n    try {\n        const queryMusicListSql = database.prepare<[], IMusic.IMusicItem>(\n            `SELECT * from \"main\".\"${`SHEET_MUSICLIST_${sheetId}`}\"\n              ORDER BY\n              \"$sortIndex\" DESC\n              `,\n        );\n        const sheetItem = database\n            .prepare<[string], IMusic.IMusicSheetItem>(\n                \"SELECT * from \\\"main\\\".\\\"localMusicSheets\\\" where id = ?\",\n            )\n            .get(sheetId);\n        return {\n            platform: sheetItem.platform,\n            id: sheetItem.id,\n            title: sheetItem.title,\n            artwork: sheetItem.artwork,\n            description: sheetItem.description,\n            createAt: sheetItem.createAt,\n            musicList: queryMusicListSql.all().map((it: any) => ({\n                ...it,\n                $raw: safeParse(it.raw),\n            })),\n            worksNum: sheetItem.worksNum,\n        };\n    } catch {\n        return null;\n    }\n}\n\nComlink.expose({\n    getSheetItem,\n});\n"
  },
  {
    "path": "src/webworkers/downloader.ts",
    "content": "import * as Comlink from \"comlink\";\nimport fs from \"fs\";\nimport fsPromises from \"fs/promises\";\nimport { Readable } from \"stream\";\nimport { encodeUrlHeaders } from \"@/common/normalize-util\";\nimport throttle from \"lodash.throttle\";\nimport { DownloadState as DownloadState } from \"@/common/constant\";\nimport { rimraf } from \"rimraf\";\n\nasync function cleanFile(filePath: string) {\n    try {\n        if ((await fsPromises.stat(filePath)).isFile()) {\n            await rimraf(filePath);\n        }\n        return true;\n    } catch {\n        return false;\n    }\n}\n\nconst responseToReadable = (\n    response: Response,\n    options?: {\n        onRead?: (size: number) => void;\n        onDone?: () => void;\n        onError?: (e: Error) => void;\n    },\n) => {\n    const reader = response.body.getReader();\n    const rs = new Readable();\n    let size = 0;\n    const tOnRead = throttle(options?.onRead, 64, {\n        leading: true,\n        trailing: true,\n    });\n    rs._read = async () => {\n        const result = await reader.read();\n        if (!result.done) {\n            rs.push(Buffer.from(result.value));\n            size += result.value.byteLength;\n            tOnRead?.(size);\n        } else {\n            rs.push(null);\n            options?.onDone?.();\n            return;\n        }\n    };\n    rs.on(\"error\", options?.onError);\n    return rs;\n};\n\ntype IOnStateChangeFunc = (data: {\n    state: DownloadState;\n    downloaded?: number;\n    total?: number;\n    msg?: string;\n}) => void;\n\nasync function downloadFile(\n    mediaSource: IMusic.IMusicSource,\n    filePath: string,\n    onStateChange: IOnStateChangeFunc,\n) {\n    let state = DownloadState.DOWNLOADING;\n    try {\n        const stat = fs.statSync(filePath);\n        // if (stat.isFile()) {\n        //   state = DownloadState.ERROR;\n        //   onStateChange?.({\n        //     state,\n        //     msg: \"File Exist\",\n        //   });\n        //   return;\n        // }\n        if (stat.isDirectory()) {\n            state = DownloadState.ERROR;\n            onStateChange?.({\n                state,\n                msg: \"Filepath is a directory\",\n            });\n            return;\n        }\n    } catch (e) {}\n    const _headers: Record<string, string> = {\n        ...(mediaSource.headers ?? {}),\n        \"user-agent\": mediaSource.userAgent,\n    };\n\n    try {\n        const urlObj = new URL(mediaSource.url);\n        let res: Response;\n        if (urlObj.username && urlObj.password) {\n            _headers[\"Authorization\"] = `Basic ${btoa(\n                `${decodeURIComponent(urlObj.username)}:${decodeURIComponent(\n                    urlObj.password,\n                )}`,\n            )}`;\n            urlObj.username = \"\";\n            urlObj.password = \"\";\n            res = await fetch(urlObj.toString(), {\n                headers: _headers,\n            });\n        } else {\n            res = await fetch(encodeUrlHeaders(mediaSource.url, _headers));\n        }\n\n        const totalSize = +res.headers.get(\"content-length\");\n        onStateChange({\n            state,\n            downloaded: 0,\n            total: totalSize,\n        });\n        const stm = responseToReadable(res, {\n            onRead(size) {\n                if (state !== DownloadState.DOWNLOADING) {\n                    return;\n                }\n                state = DownloadState.DOWNLOADING;\n                console.log(state, size, totalSize);\n                onStateChange({\n                    state,\n                    downloaded: size,\n                    total: totalSize,\n                });\n            },\n            onError: (e) => {\n                state = DownloadState.ERROR;\n                onStateChange({\n                    state,\n                    msg: e?.message,\n                });\n            },\n        }).pipe(fs.createWriteStream(filePath));\n\n        stm.on(\"close\", () => {\n            state = DownloadState.DONE;\n            onStateChange({\n                state,\n            });\n        });\n\n        stm.on(\"error\", () => {\n            // 清理文件\n            cleanFile(filePath);\n        });\n    } catch (e) {\n        state = DownloadState.ERROR;\n        onStateChange({\n            state,\n            msg: e?.message,\n        });\n        cleanFile(filePath);\n    }\n}\n\n\ninterface IOptions {\n    onProgress?: (progress: ICommon.IDownloadFileSize) => Promise<void>;\n    onEnded?: () => Promise<void>;\n    onError?: (reason: Error) => Promise<void>;\n}\nasync function downloadFileNew(\n    mediaSource: IMusic.IMusicSource,\n    filePath: string,\n    options?: IOptions,\n) {\n    let hasError = false;\n    const { onProgress: onProgressCallback, onEnded: onEndedCallback, onError: onErrorCallback } = options ?? {};\n    try {\n        const stat = fs.statSync(filePath);\n\n        if (stat.isDirectory()) {\n            hasError = true;\n            onErrorCallback?.(new Error(\"Filepath is a directory\"));\n            return;\n        }\n    } catch (e) {\n    // pass\n    }\n\n    const headers: Record<string, string> = {\n        ...(mediaSource.headers ?? {}),\n        \"user-agent\": mediaSource.userAgent,\n    };\n\n    try {\n        const urlObj = new URL(mediaSource.url);\n        let res: Response;\n        if (urlObj.username && urlObj.password) {\n            headers[\"Authorization\"] = `Basic ${btoa(\n                `${decodeURIComponent(urlObj.username)}:${decodeURIComponent(\n                    urlObj.password,\n                )}`,\n            )}`;\n            urlObj.username = \"\";\n            urlObj.password = \"\";\n            res = await fetch(urlObj.toString(), {\n                headers: headers,\n            });\n        } else {\n            res = await fetch(encodeUrlHeaders(mediaSource.url, headers));\n        }\n\n        const totalSize = +res.headers.get(\"content-length\");\n        onProgressCallback?.({\n            currentSize: 0,\n            totalSize: totalSize,\n        });\n\n\n        const stm = responseToReadable(res, {\n            onRead(size) {\n                if (hasError) {\n                    // todo abort\n                    return;\n                }\n                onProgressCallback?.({\n                    currentSize: size,\n                    totalSize: totalSize,\n                });\n            },\n            onError: (e) => {\n                if (!hasError) {\n                    hasError = true;\n                    onErrorCallback?.(e);\n                }\n            },\n        }).pipe(fs.createWriteStream(filePath));\n\n        stm.on(\"close\", () => {\n            onEndedCallback?.();\n        });\n\n        stm.on(\"error\", (e) => {\n            if (!hasError) {\n                hasError = true;\n                onErrorCallback?.(e);\n            }\n            // 清理文件\n            cleanFile(filePath);\n        });\n    } catch (e) {\n        if (!hasError) {\n            hasError = true;\n            onErrorCallback?.(e);\n        }\n        cleanFile(filePath);\n    }\n}\n\n\n\nComlink.expose({\n    downloadFile,\n    downloadFileNew,\n});\n"
  },
  {
    "path": "src/webworkers/local-file-watcher.ts",
    "content": "import * as Comlink from \"comlink\";\nimport * as chokidar from \"chokidar\";\nimport path from \"path\";\nimport { supportLocalMediaType } from \"@/common/constant\";\nimport debounce from \"lodash.debounce\";\nimport { parseLocalMusicItem } from \"@/common/file-util\";\nimport { setInternalData } from \"@/common/media-util\";\n\nlet watcher: chokidar.FSWatcher;\n\nconst addedMusicItems: IMusic.IMusicItem[] = [];\nconst removedFilePaths: string[] = [];\n\nlet _onAdd: (musicItems: IMusic.IMusicItem[]) => void;\nlet _onRemove: (filePaths: string[]) => void;\n\nasync function setupWatcher(initPaths?: string[]) {\n    watcher = chokidar.watch(initPaths ?? [], {\n        depth: 10,\n        persistent: true,\n        ignorePermissionErrors: true,\n    });\n\n    watcher.on(\"add\", async (fp, stats) => {\n        if (\n            stats.isFile() &&\n      supportLocalMediaType.some((postfix) => fp.endsWith(postfix))\n        ) {\n            const musicItem = await parseLocalMusicItem(fp);\n            musicItem.$$localPath = fp;\n            setInternalData<IMusic.IMusicItemInternalData>(\n                musicItem,\n                \"downloadData\",\n                {\n                    path: fp,\n                    quality: \"standard\",\n                },\n            );\n            addedMusicItems.push(musicItem);\n            syncAddedMusic();\n        }\n    });\n\n    watcher.on(\"unlink\", (fp) => {\n        if (supportLocalMediaType.some((postfix) => fp.endsWith(postfix))) {\n            removedFilePaths.push(fp);\n            syncRemovedFilePaths();\n        }\n    });\n}\n\nconst syncAddedMusic = debounce(\n    () => {\n        const copyOfAddedMusicItems = [...addedMusicItems];\n        addedMusicItems.length = 0;\n        _onAdd?.(copyOfAddedMusicItems);\n    },\n    500,\n    {\n        leading: false,\n        trailing: true,\n    },\n);\n\nconst syncRemovedFilePaths = debounce(\n    () => {\n        const copyOfRemovedFilePaths = [...removedFilePaths];\n        removedFilePaths.length = 0;\n        _onRemove?.(copyOfRemovedFilePaths);\n    },\n    500,\n    {\n        leading: false,\n        trailing: true,\n    },\n);\n\nasync function changeWatchPath(addPaths?: string[], rmPaths?: string[]) {\n    console.log(addPaths, rmPaths);\n    try {\n        if (addPaths?.length) {\n            watcher.add(addPaths);\n        }\n        if (rmPaths?.length) {\n            watcher.unwatch(rmPaths);\n            /**\n       * chokidar的bug: https://github.com/paulmillr/chokidar/issues/1027\n       * unwatch之后重新watch不会触发文件更新\n       */\n            rmPaths.forEach((it) => {\n                // @ts-ignore\n                const watchedDirEntry = watcher._watched.get(it);\n                if (watchedDirEntry) {\n                    // 移除所有子节点的监听\n                    watchedDirEntry._removeWatcher(\n                        path.dirname(it),\n                        path.basename(it),\n                        true,\n                    );\n                }\n                // watcher._watched.delete(it);\n            });\n        }\n    // console.log(\"WATCH PATH CHANGED\", addPaths, rmPaths, watcher);\n    } catch (e) {\n        console.log(e);\n    }\n}\n\nasync function onAdd(fn: (musicItems: IMusic.IMusicItem[]) => void) {\n    _onAdd = fn;\n}\n\nasync function onRemove(fn: (filePaths: string[]) => void) {\n    _onRemove = fn;\n}\n\nComlink.expose({\n    setupWatcher,\n    changeWatchPath,\n    onAdd,\n    onRemove,\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"target\": \"ES2021\",\n    \"allowJs\": true,\n    \"module\": \"commonjs\",\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"noImplicitAny\": true,\n    \"sourceMap\": true,\n    \"baseUrl\": \".\",\n    \"outDir\": \"dist\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"paths\": {\n      \"*\": [\"node_modules/*\"],\n      \"@/*\": [\"src/*\"],\n      \"@main/*\": [\"src/main/*\"],\n      \"@shared/*\": [\"src/shared/*\"],\n      \"@native/*\": [\"src/main/native_modules/*\"],\n      \"@renderer/*\": [\"src/renderer/*\"],\n      \"@renderer-lrc/*\": [\"src/renderer-lrc/*\"]\n    }\n  },\n  \"include\": [\"src/**/*\"]\n}\n"
  }
]