commit 38d50a814f7ea6ca244b4ea142bdda55dadf18c7 Author: Nose Date: Thu Mar 26 03:26:37 2026 -0700 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13aadf1 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +VITE_HYDRUS_HOST=http://localhost +VITE_HYDRUS_PORT=45869 +VITE_HYDRUS_API_KEY=95178b08e6ba3c57991e7b4e162c6efff1ce90c500005c6ebf8524122ed2486e +VITE_HYDRUS_SSL=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa4377c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.vscode/ +.DS_Store +/public/icon-*.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..66feee4 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# API Media Player (PWA) + +This is a small web-first PWA prototype for browsing media from a Hydrus client via its Client API. The app ships with a demo library and routes playback to external players such as mpv or VLC instead of using an in-browser player. + +## Quick start (PowerShell) + +1. Install dependencies: + +```powershell +pwsh -c "npm install" +``` + +2. Run the dev server: + +```powershell +pwsh -c "npm run dev" +``` + +Open `http://localhost:5173` in your browser to test the PWA and external-player launch flow. + +## Configuring Hydrus + +Create a `.env` file in the project root (not committed) with these variables if you want to connect to a real Hydrus instance: + +``` +VITE_HYDRUS_HOST=http://localhost +VITE_HYDRUS_PORT=45869 +VITE_HYDRUS_API_KEY= +VITE_HYDRUS_SSL=false +``` + +> Note: browsers cannot attach custom Hydrus API headers to direct media URLs. If your Hydrus server requires header-based authentication for file access, use a reverse proxy or another trusted layer that can mint playable URLs for your external player. + +### Settings UI & quick test + +On first run the app will seed a sample server entry for `192.168.1.128:45869` so you can quickly add your API key and test connectivity via the app's Settings (top-right gear). Open Settings, choose the server, paste your `Hydrus-Client-API-Access-Key` (if needed), and click **Test connection**. The test reports whether the server is reachable, whether authentication is required (HTTP 401/403), and whether byte-range requests are supported (needed for seeking). + +If you get a CORS or network error in the browser, consider running a reverse proxy that adds proper CORS headers or packaging the app with Capacitor to avoid browser CORS limitations. + +## Violentmonkey mpv userscript + +If you want desktop or Android browsers to hand media straight to mpv instead of the in-browser player, install the userscript at: + +```text +/userscripts/api-media-player-open-in-mpv.user.js +``` + +Examples: + +```text +http://localhost:5173/userscripts/api-media-player-open-in-mpv.user.js +http://127.0.0.1:4173/userscripts/api-media-player-open-in-mpv.user.js +``` + +What it does: + +- On desktop, direct media playback is redirected to `mpv-handler://...` +- On Android, direct media playback is redirected to the mpv app via `intent://...` +- It only activates by default on `localhost`, loopback, RFC1918 LAN IPs, and `.local` / `.lan` hosts so it does not hijack unrelated public websites + +Notes: + +- Desktop requires `mpv-handler` to be installed and registered. +- Android requires `mpv-android` (`is.xyz.mpv`) to be installed. +- The script intercepts direct file URLs and Hydrus `/get_files/file` playback requests before the browser player starts. + +### Desktop setup for mpv-handler + +Official project: + +```text +https://github.com/akiirui/mpv-handler +``` + +Latest releases: + +```text +https://github.com/akiirui/mpv-handler/releases +``` + +Windows: + +1. Install `mpv` itself first if you do not already have it. +2. Download the latest Windows archive: + +```text +https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip +``` + +3. Extract it somewhere permanent. +4. Edit `config.toml` in that folder and set the path to your `mpv` executable. If you use `yt-dlp`, set that path too. +5. Register the protocol with either the upstream batch file or the PowerShell installer in this repo. + +Upstream batch option: + +```powershell +Set-Location C:\path\to\mpv-handler +.\handler-install.bat +``` + +PowerShell alternative from this repo: + +```powershell +Set-Location C:\Forgejo\API-MediaPlayer +powershell -ExecutionPolicy Bypass -File .\scripts\install-mpv-handler.ps1 -InstallRoot 'C:\path\to\mpv-handler' +``` + +Or from an already elevated PowerShell window: + +```powershell +& 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1' -InstallRoot 'C:\path\to\mpv-handler' +``` + +If you copied `config.toml`, `mpv-handler.exe`, and `mpv-handler-debug.exe` into the same folder as [scripts/install-mpv-handler.ps1](scripts/install-mpv-handler.ps1), you can also run it without `-InstallRoot`: + +```powershell +& 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1' +``` + +What the PowerShell installer does: + +- validates that `config.toml`, `mpv-handler.exe`, and `mpv-handler-debug.exe` exist +- removes old `mpv://` and existing `mpv-handler://` protocol keys unless you tell it not to +- registers `mpv-handler://` and `mpv-handler-debug://` in the Windows registry +- uses the `mpv` path from `config.toml` as the icon when possible, otherwise falls back to `mpv-handler.exe` + +Requirements for the PowerShell installer: + +- run it from an elevated PowerShell window +- point `-InstallRoot` at the extracted `mpv-handler` folder +- do not dot-source it from the repo root without `-InstallRoot`, because this repo does not contain the extracted `mpv-handler` binaries + +Linux: + +1. Download the latest Linux archive: + +```text +https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip +``` + +2. Extract it. +3. Copy `mpv-handler` to `$HOME/.local/bin`. +4. Copy `mpv-handler.desktop` and `mpv-handler-debug.desktop` to `$HOME/.local/share/applications/`. +5. Make the binary executable: + +```text +chmod +x $HOME/.local/bin/mpv-handler +``` + +6. Register the protocol handlers: + +```text +xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler +xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug +``` + +7. Add `$HOME/.local/bin` to `PATH` if needed. +8. Optionally copy and edit `config.toml` for your `mpv` and `yt-dlp` paths. + +After setup, clicking an `mpv-handler://...` link in the browser should launch mpv instead of showing an unknown protocol error. + +## Next steps + +- Wire the UI to a real Hydrus instance (update `src/api/hydrusClient.ts`). +- Add Capacitor for native packaging and secure storage for API keys. +- Improve Hydrus browsing and filtering UX for large libraries. + +If you want, I can run `npm install` and start the dev server now and confirm the app is reachable locally. Let me know and I'll proceed. diff --git a/index.html b/index.html new file mode 100644 index 0000000..5203cfd --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + API Media Player + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e957392 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2465 @@ +{ + "name": "api-mediaplayer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "api-mediaplayer", + "version": "0.1.0", + "dependencies": { + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.14.0", + "@mui/material": "^5.14.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.0.0", + "prettier": "^2.8.8", + "typescript": "^5.3.0", + "vite": "^5.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e97edda --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "api-mediaplayer", + "version": "0.1.0", + "private": true, + "description": "Web-first PWA media player that integrates with Hydrus", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"" + }, + "dependencies": { + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.14.0", + "@mui/material": "^5.14.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.0.0", + "prettier": "^2.8.8", + "typescript": "^5.3.0", + "vite": "^5.1.0" + } +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..caa9099 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "API Media Player", + "short_name": "MediaPlayer", + "start_url": "/", + "display": "standalone", + "background_color": "#FFFFFF", + "theme_color": "#6200EE", + "icons": [ + { "src": "no-image.svg", "sizes": "192x192", "type": "image/svg+xml" }, + { "src": "no-image.svg", "sizes": "512x512", "type": "image/svg+xml" } + ] +} \ No newline at end of file diff --git a/public/no-image.svg b/public/no-image.svg new file mode 100644 index 0000000..7e400e1 --- /dev/null +++ b/public/no-image.svg @@ -0,0 +1,4 @@ + + + No Image + \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b2f35e4 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,169 @@ +const CACHE_NAME = 'api-mediaplayer-v1' +const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v2' +const ASSETS = ['/', '/index.html', '/manifest.json', '/no-image.svg'] +const MAX_MEDIA_CACHE_ITEMS = 12 + +function getRangeBounds(rangeHeader, size) { + const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader || '') + if (!match) return null + + let start = match[1] ? Number(match[1]) : NaN + let end = match[2] ? Number(match[2]) : NaN + + if (Number.isNaN(start) && Number.isNaN(end)) return null + if (Number.isNaN(start)) { + const suffixLength = end + if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null + start = Math.max(size - suffixLength, 0) + end = size - 1 + } else { + if (!Number.isFinite(start) || start < 0 || start >= size) return null + if (Number.isNaN(end) || end >= size) end = size - 1 + } + + if (end < start) return null + return { start, end } +} + +async function trimMediaCache(cache) { + const keys = await cache.keys() + if (keys.length <= MAX_MEDIA_CACHE_ITEMS) return + + const overflow = keys.length - MAX_MEDIA_CACHE_ITEMS + for (let index = 0; index < overflow; index += 1) { + await cache.delete(keys[index]) + } +} + +async function createRangeResponse(cachedResponse, rangeHeader) { + const blob = await cachedResponse.blob() + const size = blob.size + const bounds = getRangeBounds(rangeHeader, size) + + if (!bounds) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${size}` } + }) + } + + const { start, end } = bounds + const slice = blob.slice(start, end + 1) + const headers = new Headers(cachedResponse.headers) + headers.set('Accept-Ranges', 'bytes') + headers.set('Content-Length', String(end - start + 1)) + headers.set('Content-Range', `bytes ${start}-${end}/${size}`) + + return new Response(slice, { + status: 206, + statusText: 'Partial Content', + headers, + }) +} + +async function handleMediaRequest(event) { + const mediaCache = await caches.open(MEDIA_CACHE_NAME) + const cacheKey = event.request.url + const rangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range') + const cached = await mediaCache.match(cacheKey).catch(() => null) + + if (cached) { + if (rangeHeader) return createRangeResponse(cached, rangeHeader) + return cached + } + + const networkResponse = await fetch(event.request) + + if (!rangeHeader && networkResponse.ok && networkResponse.status === 200) { + event.waitUntil( + mediaCache.put(cacheKey, networkResponse.clone()).then(() => trimMediaCache(mediaCache)).catch(() => undefined) + ) + } + + return networkResponse +} + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS) + }).then(() => self.skipWaiting()) + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.map((k) => (k !== CACHE_NAME && k !== MEDIA_CACHE_NAME ? caches.delete(k) : Promise.resolve())))).then(() => self.clients.claim()) + ) +}) + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return + + const url = event.request.url || '' + const acceptHeader = (event.request.headers && event.request.headers.get && event.request.headers.get('accept')) || '' + const destination = event.request.destination || '' + const hasRangeHeader = event.request.headers && event.request.headers.get && event.request.headers.get('range') + const isNativeVideoBypass = /(?:\?|&)_native_video=1(?:&|$)/i.test(url) + const isVideoRequest = destination === 'video' || /video\//i.test(acceptHeader) + const isAudioRequest = destination === 'audio' || /audio\//i.test(acceptHeader) + const isMediaRequest = isVideoRequest + || isAudioRequest + || /\/get_files\/(file|thumbnail)\?/i.test(url) + || /\.(m3u8|mp4|webm|ogg|mov)(\?|$)/i.test(url) + + if (isNativeVideoBypass || isVideoRequest) { + event.respondWith(fetch(event.request)) + return + } + + if (isMediaRequest || hasRangeHeader) { + event.respondWith(handleMediaRequest(event)) + return + } + + event.respondWith((async () => { + try { + const cached = await caches.match(event.request).catch(() => null) + if (cached) return cached + + try { + const response = await fetch(event.request) + return response + } catch (networkErr) { + // On network failure, provide a graceful fallback for images and navigation + try { + const dest = event.request.destination || '' + const url = event.request.url || '' + + // Image fallback + if (dest === 'image' || /\.(png|jpe?g|gif|svg|webp)(\?|$)/i.test(url)) { + const fallback = await caches.match('/no-image.svg').catch(() => null) + if (fallback) return fallback + return new Response("No Image", { headers: { 'Content-Type': 'image/svg+xml' } }) + } + + // Navigation fallback (HTML) + if (event.request.mode === 'navigate' || (event.request.headers && event.request.headers.get && event.request.headers.get('accept') && event.request.headers.get('accept').includes('text/html'))) { + const index = await caches.match('/index.html').catch(() => null) + if (index) return index + } + } catch (e) { + console.error('SW network error fallback failed', e) + } + + return new Response('Network error', { status: 502, statusText: 'Bad Gateway' }) + } + } catch (e) { + console.error('SW fetch handler error', e) + return new Response('SW internal error', { status: 500 }) + } + })()) +}) + +// Support skip waiting via message +self.addEventListener('message', (event) => { + if (event && event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting() + } +}) diff --git a/public/userscripts/api-media-player-open-in-mpv.user.js b/public/userscripts/api-media-player-open-in-mpv.user.js new file mode 100644 index 0000000..a3233a6 --- /dev/null +++ b/public/userscripts/api-media-player-open-in-mpv.user.js @@ -0,0 +1,157 @@ +// ==UserScript== +// @name API Media Player: Open in mpv +// @namespace https://local.api-media-player +// @version 0.1.0 +// @description Redirect direct media playback from this app into mpv instead of playing inside the browser. +// @match *://*/* +// @run-at document-start +// @inject-into page +// @grant none +// ==/UserScript== + +(() => { + const RECENT_OPEN_WINDOW_MS = 1500 + const DIRECT_MEDIA_EXTENSION = /\.(mp4|m4v|mkv|webm|mov|mp3|m4a|flac|ogg|opus|wav|aac)(?:$|[?#])/i + const PRIVATE_IPV4_HOST = /^(?:10\.|127\.|192\.168\.|172\.(?:1[6-9]|2\d|3[0-1])\.)/ + const LOCAL_HOST_SUFFIX = /(?:\.local|\.lan)$/i + + let lastOpenedUrl = '' + let lastOpenedAt = 0 + + function isAllowedHost(hostname) { + return hostname === 'localhost' + || hostname === '[::1]' + || PRIVATE_IPV4_HOST.test(hostname) + || LOCAL_HOST_SUFFIX.test(hostname) + } + + function getMediaUrlFromElement(element) { + if (!element) return '' + return element.currentSrc + || element.src + || element.getAttribute('src') + || element.querySelector('source[src]')?.getAttribute('src') + || '' + } + + function isDirectMediaUrl(rawUrl) { + if (!rawUrl) return false + + try { + const url = new URL(rawUrl, location.href) + if (!/^https?:$/i.test(url.protocol)) return false + + return DIRECT_MEDIA_EXTENSION.test(url.pathname) + || /\/get_files\/file$/i.test(url.pathname) + || url.searchParams.has('file_id') + } catch { + return false + } + } + + function encodeUrlSafeBase64(value) { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '') + } + + function looksLikeVideo(url, element) { + if (element?.tagName === 'VIDEO') return true + + try { + const pathname = new URL(url, location.href).pathname + return /\.(mp4|m4v|mkv|webm|mov)$/i.test(pathname) + } catch { + return false + } + } + + function buildDesktopMpvUrl(url, title) { + const encodedUrl = encodeUrlSafeBase64(url) + const encodedTitle = title ? encodeUrlSafeBase64(title) : '' + const query = encodedTitle ? `?v_title=${encodedTitle}` : '' + return `mpv-handler://play/${encodedUrl}/${query}` + } + + function buildAndroidMpvUrl(url, element) { + try { + const parsed = new URL(url, location.href) + const scheme = parsed.protocol.replace(':', '') || 'https' + const intentPath = `${parsed.host}${parsed.pathname}${parsed.search}` + const mimeType = looksLikeVideo(url, element) ? 'video/*' : 'audio/*' + return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end` + } catch { + return url + } + } + + function buildExternalPlayerUrl(url, title, element) { + if (/Android/i.test(navigator.userAgent || '')) { + return buildAndroidMpvUrl(url, element) + } + + return buildDesktopMpvUrl(url, title) + } + + function openInExternalPlayer(url, element) { + const now = Date.now() + if (url === lastOpenedUrl && now - lastOpenedAt < RECENT_OPEN_WINDOW_MS) { + return + } + + lastOpenedUrl = url + lastOpenedAt = now + + const title = document.title || '' + const targetUrl = buildExternalPlayerUrl(url, title, element) + location.assign(targetUrl) + } + + function shouldInterceptCurrentPage() { + return isAllowedHost(location.hostname) + } + + function rejectPlaybackRedirect() { + return Promise.reject(new DOMException('Playback redirected to external player', 'AbortError')) + } + + function interceptElementPlayback(element) { + if (!shouldInterceptCurrentPage()) return false + + const mediaUrl = getMediaUrlFromElement(element) + if (!isDirectMediaUrl(mediaUrl)) return false + + try { element.pause() } catch {} + try { element.removeAttribute('src') } catch {} + try { element.load() } catch {} + + openInExternalPlayer(mediaUrl, element) + return true + } + + const originalPlay = HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = function play(...args) { + if (interceptElementPlayback(this)) { + return rejectPlaybackRedirect() + } + + return originalPlay.apply(this, args) + } + + document.addEventListener('click', (event) => { + if (!shouldInterceptCurrentPage()) return + + const target = event.target instanceof Element ? event.target.closest('a[href]') : null + if (!target) return + + const href = target.getAttribute('href') || '' + if (!isDirectMediaUrl(href)) return + + event.preventDefault() + event.stopPropagation() + openInExternalPlayer(href) + }, true) +})() \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..829871e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,155 @@ +[English][readme-en] | [简体中文][readme-zh-hans] | [繁体中文][readme-zh-hant] + +[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md +[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md +[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md + +# mpv handler + +A protocol handler for **mpv**, written by Rust. + +Use **mpv** and **yt-dlp** to play video and music from the websites. + +Please use it with userscript: + +[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv] + +## Breaking changes + +### [v0.4.0][v0.4.0] + +To avoid conflicts with the `mpv://` protocol provided by mpv. + +> mpv://... +> +> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way. + +Scheme `mpv://` and `mpv-debug://` are deprecated, use `mpv-handler://` and `mpv-handler-debug://`. + +**Require manual intervention** + +#### Windows + +Run `handler-uninstall.bat` to uninstall deprecated protocol, and run `handler-install.bat` to install new procotol. + +#### Linux + +If you installed manually, please repeat the manual installation process. + +## Protocol + +![](share/proto.png) + +### Scheme + +- `mpv-handler`: Run mpv-handler without console window +- `mpv-handler-debug`: Run mpv-handler with console window to view outputs and errors + +### Plugins + +- `play`: Use mpv player to play video + +### Encoded Data + +Use [URL-safe base64][rfc-base64-url] to encode the URL or TITLE. + +Replace `/` to `_`, `+` to `-` and remove padding `=`. + +Example (JavaScript): + +```javascript +let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU"); +let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, ""); +``` + +### Parameters (Optional) + +``` +cookies = [ www.domain.com.txt ] +profile = [ default, low-latency, etc... ] +quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ] +v_codec = [ av01, vp9, h265, h264 ] +v_title = [ Encoded Title ] +subfile = [ Encoded URL ] +startat = [ Seconds (float) ] +referrer = [ Encoded URL ] +``` + +## Installation + +### Linux + +#### Arch Linux + +[![mpv-handler][badges-aur]][download-aur] +[![mpv-handler-git][badges-aur-git]][download-aur-git] + +#### Manual installation + +1. Download [latest Linux release][download-linux] +2. Unzip the archive +3. Copy `mpv-handler` to `$HOME/.local/bin` +4. Copy `mpv-handler.desktop` to `$HOME/.local/share/applications/` +5. Copy `mpv-handler-debug.desktop` to `$HOME/.local/share/applications/` +6. Set executable permission for binary + + - ``` + $ chmod +x $HOME/.local/bin/mpv-handler + ``` + +7. Register xdg-mime (thanks for the [linuxuprising][linuxuprising] reminder) + + - ``` + $ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler + $ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug + ``` + +8. Add `$HOME/.local/bin` to your environment variable `PATH` +9. **Optional**: _Copy `config.toml` to `$HOME/.config/mpv-handler/config.toml` and configure_ + +### Windows + +Windows users need to install manually. + +#### Manual installation + +1. Download [latest Windows release][download-windows] +2. Unzip the archive to the directory you want +3. Run `handler-install.bat` to register protocol handler +4. Edit `config.toml` and set `mpv` and `ytdl` path + +## Configuration + +```toml +mpv = "/usr/bin/mpv" +# Optional, Type: String +# The path of mpv executable binary +# Default value: +# - Linux: mpv +# - Windows: mpv.com + +ytdl = "/usr/bin/yt-dlp" +# Optional, Type: String +# The path of yt-dlp executable binary + +proxy = "http://example.com:8080" +# Optional, Type: String +# HTTP(S) proxy server address + +# For Windows users: +# - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe" +# - The path target is an executable binary file, not a directory +``` + +[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0 +[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5 +[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git +[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler +[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv +[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/ +[download-aur]: https://aur.archlinux.org/packages/mpv-handler/ +[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip +[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip +[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip +[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv +[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html diff --git a/scripts/README.zh-Hans.md b/scripts/README.zh-Hans.md new file mode 100644 index 0000000..892643e --- /dev/null +++ b/scripts/README.zh-Hans.md @@ -0,0 +1,155 @@ +[English][readme-en] | [简体中文][readme-zh-hans] | [繁体中文][readme-zh-hant] + +[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md +[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md +[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md + +# mpv handler + +一个 **mpv** 的协议处理程序,使用 Rust 编写。 + +使用 **mpv** 和 **yt-dlp** 播放网站上的视频与音乐。 + +请配合用户脚本使用: + +[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv] + +## 重大变更 + +### [v0.4.0][v0.4.0] + +为了避免与 mpv 所提供的 `mpv://` 协议冲突。 + +> mpv://... +> +> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way. + +协议 `mpv://` 和 `mpv-debug://` 已弃用, 请使用 `mpv-handler://` 和 `mpv-handler-debug://`. + +**需要手动干预** + +#### Windows + +运行 `handler-uninstall.bat` 卸载已弃用的协议, 然后运行 `handler-install.bat` 安装新的协议. + +#### Linux + +如果你是手动安装的,请重新执行一遍手动安装流程。 + +## 协议 + +![](share/proto.png) + +### 协议名 + +- `mpv-handler`: 在没有命令行窗口的情况下运行 mpv-handler +- `mpv-handler-debug`: 在有命令行窗口的情况下运行 mpv-handler 以便于查看输出和错误 + +### 插件 / Plugins + +- `play`: 使用 mpv 播放视频 + +### 编码数据 / Encoded Data + +使用 [URL 安全的 base64][rfc-base64-url] 编码网址或标题。 + +替换 `/` 至 `_`, `+` 至 `-` 并且删除填充的 `=`。 + +示例 (JavaScript): + +```javascript +let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU"); +let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, ""); +``` + +### 参数 / Parameters (可选) + +``` +cookies = [ www.domain.com.txt ] +profile = [ default, low-latency, etc... ] +quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ] +v_codec = [ av01, vp9, h265, h264 ] +v_title = [ Encoded Title ] +subfile = [ Encoded URL ] +startat = [ Seconds (float) ] +referrer = [ Encoded URL ] +``` + +## 安装 + +### Linux + +#### Arch Linux + +[![mpv-handler][badges-aur]][download-aur] +[![mpv-handler-git][badges-aur-git]][download-aur-git] + +#### 手动安装 + +1. 下载 [最新的 Linux 压缩包][download-linux] +2. 解压缩压缩包 +3. 复制 `mpv-handler` 至 `$HOME/.local/bin` +4. 复制 `mpv-handler.desktop` 至 `$HOME/.local/share/applications/` +5. 复制 `mpv-handler-debug.desktop` 至 `$HOME/.local/share/applications/` +6. 为二进制文件设置可执行权限 + + - ``` + $ chmod +x $HOME/.local/bin/mpv-handler + ``` + +7. 注册 xdg-mime(感谢 [linuxuprising][linuxuprising] 的提醒) + + - ``` + $ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler + $ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug + ``` + +8. 添加 `$HOME/.local/bin` 到环境变量 `PATH` +9. **可选**: _复制 `config.toml` 至 `$HOME/.config/mpv-handler/config.toml` 并配置_ + +### Windows + +Windows 用户目前只能手动安装。 + +#### 手动安装 + +1. 下载 [最新的 Windows 压缩包][download-windows] +2. 解压缩档案到你想要的位置 +3. 运行 `handler-install.bat` 注册协议处理程序 +4. 编辑 `config.toml` 设置 `mpv` 和 `ytdl` 的路径 + +## 配置 + +```toml +mpv = "/usr/bin/mpv" +# 可选,类型:字符串 +# mpv 可执行文件的路径 +# 默认值: +# - Linux: mpv +# - Windows: mpv.com + +ytdl = "/usr/bin/yt-dlp" +# 可选,类型:字符串 +# yt-dlp 可执行文件的路径 + +proxy = "http://example.com:8080" +# 可选,类型:字符串 +# HTTP(S) 代理服务器的地址 + +# 对于 Windows 用户: +# - 路径格式可以是 "C:\\folder\\some.exe",也可以是 "C:/folder/some.exe" +# - 路径的目标是可执行二进制文件,而不是目录 +``` + +[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0 +[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5 +[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git +[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler +[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv +[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/ +[download-aur]: https://aur.archlinux.org/packages/mpv-handler/ +[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip +[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip +[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip +[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv +[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html diff --git a/scripts/README.zh-Hant.md b/scripts/README.zh-Hant.md new file mode 100644 index 0000000..f1c49c1 --- /dev/null +++ b/scripts/README.zh-Hant.md @@ -0,0 +1,155 @@ +[English][readme-en] | [簡體中文][readme-zh-hans] | [繁體中文][readme-zh-hant] + +[readme-en]: https://github.com/akiirui/mpv-handler/blob/main/README.md +[readme-zh-hans]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hans.md +[readme-zh-hant]: https://github.com/akiirui/mpv-handler/blob/main/README.zh-Hant.md + +# mpv handler + +一個 **mpv** 的協議處理程序,使用 Rust 編寫。 + +使用 **mpv** 和 **yt-dlp** 播放網站上的視頻與音樂。 + +請配合用戶腳本使用: + +[![play-with-mpv][badges-play-with-mpv]][greasyfork-play-with-mpv] + +## 重大變更 + +### [v0.4.0][v0.4.0] + +爲了避免與 mpv 所提供的 `mpv://` 協議衝突。 + +> mpv://... +> +> mpv protocol. This is used for starting mpv from URL handler. The protocol is stripped and the rest is passed to the player as a normal open argument. Only safe network protocols are allowed to be opened this way. + +協議 `mpv://` 和 `mpv-debug://` 已棄用, 請使用 `mpv-handler://` 和 `mpv-handler-debug://`. + +**需要手動干預** + +#### Windows + +運行 `handler-uninstall.bat` 卸載已棄用的協議, 然後運行 `handler-install.bat` 安裝新的協議. + +#### Linux + +如果你是手動安裝的,請重新執行一遍手動安裝流程。 + +## 協議 + +![](share/proto.png) + +### 協議名 + +- `mpv-handler`: 在沒有命令行窗口的情況下運行 mpv-handler +- `mpv-handler-debug`: 在有命令行窗口的情況下運行 mpv-handler 以便於查看輸出和錯誤 + +### 插件 / Plugins + +- `play`: 使用 mpv 播放視頻 + +### 編碼數據 / Encoded Data + +使用 [URL 安全的 base64][rfc-base64-url] 編碼網址或標題。 + +替換 `/` 至 `_`, `+` 至 `-` 並且刪除填充的 `=`。 + +示例 (JavaScript): + +```javascript +let data = btoa("https://www.youtube.com/watch?v=Ggkn2f5e-IU"); +let safe = data.replace(/\//g, "_").replace(/\+/g, "-").replace(/\=/g, ""); +``` + +### 參數 / Parameters (可選) + +``` +cookies = [ www.domain.com.txt ] +profile = [ default, low-latency, etc... ] +quality = [ 2160p, 1440p, 1080p, 720p, 480p, 360p ] +v_codec = [ av01, vp9, h265, h264 ] +v_title = [ Encoded Title ] +subfile = [ Encoded URL ] +startat = [ Seconds (float) ] +referrer = [ Encoded URL ] +``` + +## 安裝 + +### Linux + +#### Arch Linux + +[![mpv-handler][badges-aur]][download-aur] +[![mpv-handler-git][badges-aur-git]][download-aur-git] + +#### 手動安裝 + +1. 下載 [最新的 Linux 壓縮包][download-linux] +2. 解壓縮壓縮包 +3. 複製 `mpv-handler` 至 `$HOME/.local/bin` +4. 複製 `mpv-handler.desktop` 至 `$HOME/.local/share/applications/` +5. 複製 `mpv-handler-debug.desktop` 至 `$HOME/.local/share/applications/` +6. 爲二進制文件設置可執行權限 + + - ``` + $ chmod +x $HOME/.local/bin/mpv-handler + ``` + +7. 註冊 xdg-mime(感謝 [linuxuprising][linuxuprising] 的提醒) + + - ``` + $ xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler + $ xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug + ``` + +8. 添加 `$HOME/.local/bin` 到環境變量 `PATH` +9. **可選**: _複製 `config.toml` 至 `$HOME/.config/mpv-handler/config.toml` 並配置_ + +### Windows + +Windows 用戶目前只能手動安裝。 + +#### 手動安裝 + +1. 下載 [最新的 Windows 壓縮包][download-windows] +2. 解壓縮檔案到你想要的位置 +3. 運行 `handler-install.bat` 註冊協議處理程序 +4. 編輯 `config.toml` 設置 `mpv` 和 `ytdl` 的路徑 + +## 配置 + +```toml +mpv = "/usr/bin/mpv" +# 可選,類型:字符串 +# mpv 可執行文件的路徑 +# 默認值: +# - Linux: mpv +# - Windows: mpv.com + +ytdl = "/usr/bin/yt-dlp" +# 可選,類型:字符串 +# yt-dlp 可執行文件的路徑 + +proxy = "http://example.com:8080" +# 可選,類型:字符串 +# HTTP(S) 代理服務器的地址 + +# 對於 Windows 用戶: +# - 路徑格式可以是 "C:\\folder\\some.exe",也可以是 "C:/folder/some.exe" +# - 路徑的目標是可執行二進制文件,而不是目錄 +``` + +[v0.4.0]: https://github.com/akiirui/mpv-handler/releases/tag/v0.4.0 +[rfc-base64-url]: https://datatracker.ietf.org/doc/html/rfc4648#section-5 +[badges-aur-git]: https://img.shields.io/aur/version/mpv-handler-git?style=for-the-badge&logo=archlinux&label=mpv-handler-git +[badges-aur]: https://img.shields.io/aur/version/mpv-handler?style=for-the-badge&logo=archlinux&label=mpv-handler +[badges-play-with-mpv]: https://img.shields.io/greasyfork/v/416271?style=for-the-badge&logo=greasyfork&label=play-with-mpv +[download-aur-git]: https://aur.archlinux.org/packages/mpv-handler-git/ +[download-aur]: https://aur.archlinux.org/packages/mpv-handler/ +[download-linux]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip +[download-macos]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-macos-amd64.zip +[download-windows]: https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-windows-amd64.zip +[greasyfork-play-with-mpv]: https://greasyfork.org/scripts/416271-play-with-mpv +[linuxuprising]: https://www.linuxuprising.com/2021/07/open-youtube-and-more-videos-from-your.html diff --git a/scripts/config.toml b/scripts/config.toml new file mode 100644 index 0000000..05c7190 --- /dev/null +++ b/scripts/config.toml @@ -0,0 +1,18 @@ +#mpv = "C:\\path\\of\\mpv.com" +# Optional, Type: String +# The path of mpv executable binary +# Default value: +# - Linux: mpv +# - Windows: mpv.com + +#ytdl = "C:\\path\\of\\yt-dlp.exe" +# Optional, Type: String +# The path of yt-dlp executable binary + +#proxy = "http://example.com:8080" +# Optional, Type: String +# HTTP(S) proxy server address + +# For Windows users: +# - The path can be "C:\\folder\\some.exe" or "C:/folder/some.exe" +# - The path is an executable binary file, not a directory diff --git a/scripts/handler-install.bat b/scripts/handler-install.bat new file mode 100644 index 0000000..844b580 --- /dev/null +++ b/scripts/handler-install.bat @@ -0,0 +1,82 @@ +@echo OFF + +:: Unattended install flag. When set, the script will not require user input. +set unattended=no +if "%1"=="/u" set unattended=yes + +:: Make sure this is Windows Vista or later +call :ensure_vista + +:: Make sure the script is running as admin +call :ensure_admin + +:: Get mpv.exe location +call :check_binary + +:: Add registry +call :add_verbs + +:die + if not [%1] == [] echo %~1 + if [%unattended%] == [yes] exit 1 + pause + exit 1 + +:ensure_admin + :: 'openfiles' is just a commmand that is present on all supported Windows + :: versions, requires admin privileges and has no side effects, see: + :: https://stackoverflow.com/questions/4051883/batch-script-how-to-check-for-admin-rights + openfiles >nul 2>&1 + if errorlevel 1 ( + echo This batch script requires administrator privileges. + echo Right-click on handler-install.bat and select "Run as administrator". + call :die + ) + goto :EOF + +:ensure_vista + ver | find "XP" >nul + if not errorlevel 1 ( + echo This batch script only works on Windows Vista and later. To create file + echo associations on Windows XP, right click on a video file and use "Open with...". + call :die + ) + goto :EOF + +:check_binary + cd /D %~dp0 + set mpv_handler_conf=%cd%\config.toml + set mpv_handler_path=%cd%\mpv-handler.exe + set mpv_handler_debug_path=%cd%\mpv-handler-debug.exe + if not exist "%mpv_handler_conf%" call :die "Not found config.toml" + if not exist "%mpv_handler_path%" call :die "Not found mpv-handler.exe" + if not exist "%mpv_handler_debug_path%" call :die "Not found mpv-handler-debug.exe" + goto :EOF + +:reg + :: Wrap the reg command to check for errors + >nul reg %* + if errorlevel 1 set error=yes + if [%error%] == [yes] echo Error in command: reg %* + if [%error%] == [yes] call :die + goto :EOF + +:add_verbs + :: Add the mpv protocol to the registry + call :reg add "HKCR\mpv-handler" /d "URL:MPV Handler" /f + call :reg add "HKCR\mpv-handler" /v "Content Type" /d "application/x-mpv-handler" /f + call :reg add "HKCR\mpv-handler" /v "URL Protocol" /f + call :reg add "HKCR\mpv-handler\DefaultIcon" /d "\"%mpv_exe_path%\",1" /f + call :reg add "HKCR\mpv-handler\shell\open\command" /d "\"%mpv_handler_path%\" \"%%%%1\"" /f + + :: Add the mpv protocol to the registry + call :reg add "HKCR\mpv-handler-debug" /d "URL:MPV Handler Debug" /f + call :reg add "HKCR\mpv-handler-debug" /v "Content Type" /d "application/x-mpv-handler-debug" /f + call :reg add "HKCR\mpv-handler-debug" /v "URL Protocol" /f + call :reg add "HKCR\mpv-handler-debug\DefaultIcon" /d "\"%mpv_exe_path%\",1" /f + call :reg add "HKCR\mpv-handler-debug\shell\open\command" /d "\"%mpv_handler_debug_path%\" \"%%%%1\"" /f + + echo Successfully installed mpv-handler + echo Enjoy! + + goto :EOF diff --git a/scripts/handler-uninstall.bat b/scripts/handler-uninstall.bat new file mode 100644 index 0000000..963bab9 --- /dev/null +++ b/scripts/handler-uninstall.bat @@ -0,0 +1,64 @@ +@echo OFF + +:: Unattended install flag. When set, the script will not require user input. +set unattended=no +if "%1"=="/u" set unattended=yes + +:: Make sure this is Windows Vista or later +call :ensure_vista + +:: Make sure the script is running as admin +call :ensure_admin + +:: Delete registry +call :del_verbs + + + +:die + if not [%1] == [] echo %~1 + if [%unattended%] == [yes] exit 1 + pause + exit 1 + +:ensure_admin + :: 'openfiles' is just a commmand that is present on all supported Windows + :: versions, requires admin privileges and has no side effects, see: + :: https://stackoverflow.com/questions/4051883/batch-script-how-to-check-for-admin-rights + openfiles >nul 2>&1 + if errorlevel 1 ( + echo This batch script requires administrator privileges. + echo Right-click on handler-uninstall.bat and select "Run as administrator". + call :die + ) + goto :EOF + +:ensure_vista + ver | find "XP" >nul + if not errorlevel 1 ( + echo This batch script only works on Windows Vista and later. To create file + echo associations on Windows XP, right click on a video file and use "Open with...". + call :die + ) + goto :EOF + +:reg + :: Wrap the reg command to check for errors + >nul reg %* + if errorlevel 1 set error=yes + if [%error%] == [yes] echo Error in command: reg %* + if [%error%] == [yes] call :die + goto :EOF + +:del_verbs + :: Delete deprecated mpv and mpv-debug protocol + call :reg delete "HKCR\mpv" /f + call :reg delete "HKCR\mpv-debug" /f + + :: Delete protocol + call :reg delete "HKCR\mpv-handler" /f + call :reg delete "HKCR\mpv-handler-debug" /f + + echo Successfully uninstalled mpv-handler + + goto :EOF diff --git a/scripts/install-mpv-handler.ps1 b/scripts/install-mpv-handler.ps1 new file mode 100644 index 0000000..3c0989b --- /dev/null +++ b/scripts/install-mpv-handler.ps1 @@ -0,0 +1,229 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +[CmdletBinding()] +param( + [string]$InstallRoot, + [string]$IconPath, + [switch]$KeepExistingProtocolKeys +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-ExistingFile { + param( + [Parameter(Mandatory = $true)] + [string]$BasePath, + + [Parameter(Mandatory = $true)] + [string]$FileName + ) + + $resolvedPath = Join-Path -Path $BasePath -ChildPath $FileName + if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { + throw "Required file not found: $resolvedPath`nInstallRoot must point at the extracted mpv-handler folder that contains config.toml, mpv-handler.exe, and mpv-handler-debug.exe." + } + + return (Resolve-Path -LiteralPath $resolvedPath).Path +} + +function Get-UsageExample { + return @" +Example usage: + powershell -ExecutionPolicy Bypass -File .\scripts\install-mpv-handler.ps1 -InstallRoot 'C:\path\to\mpv-handler' + +Or from an elevated PowerShell session: + & 'C:\Forgejo\API-MediaPlayer\scripts\install-mpv-handler.ps1' -InstallRoot 'C:\path\to\mpv-handler' + +If config.toml, mpv-handler.exe, and mpv-handler-debug.exe are in the same folder as this script, +you can run it without -InstallRoot. +"@ +} + +function Test-InstallRootContents { + param( + [string]$CandidatePath + ) + + if (-not $CandidatePath) { + return $false + } + + if (-not (Test-Path -LiteralPath $CandidatePath -PathType Container)) { + return $false + } + + return ( + (Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'config.toml') -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'mpv-handler.exe') -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path -Path $CandidatePath -ChildPath 'mpv-handler-debug.exe') -PathType Leaf) + ) +} + +function Get-DefaultInstallRoot { + if (Test-InstallRootContents -CandidatePath $PSScriptRoot) { + return (Resolve-Path -LiteralPath $PSScriptRoot).Path + } + + return $null +} + +function Assert-InstallRoot { + param( + [string]$CandidatePath + ) + + if (-not $CandidatePath) { + $defaultInstallRoot = Get-DefaultInstallRoot + if ($defaultInstallRoot) { + return $defaultInstallRoot + } + + throw "InstallRoot is required.`n$(Get-UsageExample)" + } + + if (-not (Test-Path -LiteralPath $CandidatePath -PathType Container)) { + throw "InstallRoot does not exist or is not a folder: $CandidatePath`n$(Get-UsageExample)" + } + + $resolved = (Resolve-Path -LiteralPath $CandidatePath).Path + + if (Test-Path -LiteralPath (Join-Path -Path $resolved -ChildPath 'package.json') -PathType Leaf) { + throw "InstallRoot appears to be this repo, not the extracted mpv-handler folder: $resolved`n$(Get-UsageExample)" + } + + return $resolved +} + +function Get-MpvPathFromConfig { + param( + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { + return $null + } + + $raw = Get-Content -LiteralPath $ConfigPath -Raw + $match = [regex]::Match($raw, '(?m)^\s*mpv\s*=\s*"(?[^"]+)"\s*$') + if (-not $match.Success) { + return $null + } + + $candidate = $match.Groups['path'].Value.Trim() + if (-not $candidate) { + return $null + } + + if ([System.IO.Path]::IsPathRooted($candidate) -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { + return (Resolve-Path -LiteralPath $candidate).Path + } + + try { + $command = Get-Command -Name $candidate -ErrorAction Stop + return $command.Source + } catch { + return $null + } +} + +function Remove-ProtocolKey { + param( + [Parameter(Mandatory = $true)] + [string]$SchemeName + ) + + $classesRoot = [Microsoft.Win32.Registry]::ClassesRoot + try { + $classesRoot.DeleteSubKeyTree($SchemeName, $false) + } catch { + } +} + +function Register-Protocol { + param( + [Parameter(Mandatory = $true)] + [string]$SchemeName, + + [Parameter(Mandatory = $true)] + [string]$Description, + + [Parameter(Mandatory = $true)] + [string]$ContentType, + + [Parameter(Mandatory = $true)] + [string]$HandlerExecutable, + + [Parameter(Mandatory = $true)] + [string]$EffectiveIconPath + ) + + $classesRoot = [Microsoft.Win32.Registry]::ClassesRoot + $schemeKey = $classesRoot.CreateSubKey($SchemeName) + if (-not $schemeKey) { + throw "Failed to create registry key for $SchemeName" + } + + try { + $schemeKey.SetValue('', $Description, [Microsoft.Win32.RegistryValueKind]::String) + $schemeKey.SetValue('Content Type', $ContentType, [Microsoft.Win32.RegistryValueKind]::String) + $schemeKey.SetValue('URL Protocol', '', [Microsoft.Win32.RegistryValueKind]::String) + + $defaultIconKey = $schemeKey.CreateSubKey('DefaultIcon') + try { + $defaultIconKey.SetValue('', ('"{0}",1' -f $EffectiveIconPath), [Microsoft.Win32.RegistryValueKind]::String) + } finally { + $defaultIconKey.Dispose() + } + + $commandKey = $schemeKey.CreateSubKey('shell\open\command') + try { + $commandKey.SetValue('', ('"{0}" "%1"' -f $HandlerExecutable), [Microsoft.Win32.RegistryValueKind]::String) + } finally { + $commandKey.Dispose() + } + } finally { + $schemeKey.Dispose() + } +} + +if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { + throw 'This installer is only for Windows.' +} + +$installRootPath = Assert-InstallRoot -CandidatePath $InstallRoot +$configPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'config.toml' +$handlerPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'mpv-handler.exe' +$handlerDebugPath = Resolve-ExistingFile -BasePath $installRootPath -FileName 'mpv-handler-debug.exe' + +$effectiveIconPath = if ($IconPath) { + if (-not (Test-Path -LiteralPath $IconPath -PathType Leaf)) { + throw "IconPath does not exist: $IconPath" + } + (Resolve-Path -LiteralPath $IconPath).Path +} else { + Get-MpvPathFromConfig -ConfigPath $configPath +} + +if (-not $effectiveIconPath) { + $effectiveIconPath = $handlerPath +} + +if (-not $KeepExistingProtocolKeys) { + Remove-ProtocolKey -SchemeName 'mpv' + Remove-ProtocolKey -SchemeName 'mpv-debug' + Remove-ProtocolKey -SchemeName 'mpv-handler' + Remove-ProtocolKey -SchemeName 'mpv-handler-debug' +} + +Register-Protocol -SchemeName 'mpv-handler' -Description 'URL:MPV Handler' -ContentType 'application/x-mpv-handler' -HandlerExecutable $handlerPath -EffectiveIconPath $effectiveIconPath +Register-Protocol -SchemeName 'mpv-handler-debug' -Description 'URL:MPV Handler Debug' -ContentType 'application/x-mpv-handler-debug' -HandlerExecutable $handlerDebugPath -EffectiveIconPath $effectiveIconPath + +Write-Host 'Successfully installed mpv-handler protocol registration.' -ForegroundColor Green +Write-Host ('InstallRoot: {0}' -f $installRootPath) +Write-Host ('Handler: {0}' -f $handlerPath) +Write-Host ('Debug Handler: {0}' -f $handlerDebugPath) +Write-Host ('Icon: {0}' -f $effectiveIconPath) +Write-Host 'You can now test an mpv-handler:// URL from the browser.' \ No newline at end of file diff --git a/scripts/mpv-handler-debug.exe b/scripts/mpv-handler-debug.exe new file mode 100644 index 0000000..9330be5 Binary files /dev/null and b/scripts/mpv-handler-debug.exe differ diff --git a/scripts/mpv-handler.exe b/scripts/mpv-handler.exe new file mode 100644 index 0000000..98559b6 Binary files /dev/null and b/scripts/mpv-handler.exe differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..9ab548b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,357 @@ +import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react' +import Library from './pages/Library' +import Header from './components/Header' +import Sidebar from './components/Sidebar' +import { Box, CssBaseline, useMediaQuery } from '@mui/material' +import { ThemeProvider, createTheme } from '@mui/material/styles' +import { ServersProvider } from './context/ServersContext' +import { addDevLog } from './debugLog' +import { loadUiPreferences, saveUiPreferences } from './appPreferences' +import type { MediaSection, Track } from './types' + +const SettingsPage = lazy(() => import('./pages/SettingsPage')) +const DevErrorPanel = lazy(() => import('./components/DevErrorPanel')) + +const VIDEO_URL_PATTERN = /\.(m3u8|mp4|webm|ogv|mov|mkv|avi|wmv)$/i +const AUDIO_URL_PATTERN = /\.(mp3|m4a|aac|flac|wav|ogg|opus|oga|wma)$/i + +type MediaInfo = { + mimeType?: string + isVideo?: boolean +} + +type ExternalPlayerTarget = { + href: string + appName: string +} + +function isPlayableMediaTrack(track: Track) { + const mimeType = normalizeMimeForPlaybackCheck(track.mimeType) + if (track.isVideo) return true + if (mimeType.startsWith('audio/') || mimeType.startsWith('video/')) return true + if (mimeType.includes('mpegurl')) return true + if (VIDEO_URL_PATTERN.test(track.url)) return true + if (AUDIO_URL_PATTERN.test(track.url)) return true + return false +} + +function normalizeMimeForPlaybackCheck(mimeType?: string) { + return (mimeType || '').trim().toLowerCase() +} + +function encodeUrlSafeBase64(value: string) { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '') +} + +function buildVlcStreamUrl(track: Track) { + const url = `vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(track.url)}` + const fileName = (track.title || '').trim() + if (!fileName) return url + return `${url}&filename=${encodeURIComponent(fileName)}` +} + +function buildAndroidMpvIntentUrl(track: Track) { + try { + const url = new URL(track.url) + const scheme = url.protocol.replace(':', '') || 'https' + const intentPath = `${url.host}${url.pathname}${url.search}` + const mimeType = track.isVideo || normalizeMimeForPlaybackCheck(track.mimeType).startsWith('video/') + ? 'video/*' + : normalizeMimeForPlaybackCheck(track.mimeType).startsWith('audio/') + ? 'audio/*' + : 'video/any' + + return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end` + } catch { + return track.url + } +} + +function buildDesktopMpvHandlerUrl(track: Track) { + const encodedUrl = encodeUrlSafeBase64(track.url) + const title = (track.title || '').trim() + const query = title ? `?v_title=${encodeUrlSafeBase64(title)}` : '' + return `mpv-handler://play/${encodedUrl}/${query}` +} + +function App() { + const initialUiPreferences = useMemo(() => loadUiPreferences(), []) + const [activePage, setActivePage] = useState('audio') + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) + const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true) + const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery) + const [libraryDisplayMode, setLibraryDisplayMode] = useState(initialUiPreferences.libraryDisplayMode) + const [devOverlayEnabled, setDevOverlayEnabled] = useState(initialUiPreferences.devOverlayEnabled) + + const theme = useMemo(() => createTheme({ + palette: { + mode: 'dark', + primary: { main: '#1db954' }, + background: { default: '#0f1113', paper: '#151617' } + } + }), []) + + const mimeCacheRef = useRef>({}) + const mimeRequestCacheRef = useRef>>({}) + const playRequestAbortRef = useRef(null) + const lastBrowsePageRef = useRef(activePage === 'settings' ? 'audio' : activePage) + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '' + const platform = typeof navigator !== 'undefined' ? navigator.platform || '' : '' + const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints || 0 : 0 + const isIosPhone = /iPhone|iPod/i.test(userAgent) + const isIpad = /iPad/i.test(userAgent) || (/Mac/i.test(platform) && maxTouchPoints > 1) + const isAppleMobileOrTablet = isIosPhone || isIpad + const isAndroidMobileOrTablet = /Android/i.test(userAgent) + const isDesktopLayout = useMediaQuery(theme.breakpoints.up('md')) + + useEffect(() => { + return () => { + try { playRequestAbortRef.current?.abort() } catch {} + } + }, []) + + useEffect(() => { + if (activePage !== 'settings') { + lastBrowsePageRef.current = activePage + } + }, [activePage]) + + useEffect(() => { + saveUiPreferences({ libraryQuery }) + }, [libraryQuery]) + + const getPreferredExternalPlayer = useCallback((track: Track): ExternalPlayerTarget => { + if (isAppleMobileOrTablet) { + return { + href: buildVlcStreamUrl(track), + appName: 'VLC', + } + } + + if (isAndroidMobileOrTablet) { + return { + href: buildAndroidMpvIntentUrl(track), + appName: 'mpv', + } + } + + return { + href: buildDesktopMpvHandlerUrl(track), + appName: 'mpv', + } + }, [isAndroidMobileOrTablet, isAppleMobileOrTablet]) + + const openInPreferredExternalPlayer = useCallback((track: Track) => { + if (!isPlayableMediaTrack(track)) { + addDevLog({ + kind: 'debug', + category: 'playback', + message: 'Opening non-media file in browser tab', + details: { + trackId: track.id, + fileId: track.fileId, + mimeType: track.mimeType, + href: track.url, + } + }) + const openedWindow = window.open(track.url, '_blank', 'noopener,noreferrer') + if (!openedWindow) { + window.location.href = track.url + } + return + } + + const target = getPreferredExternalPlayer(track) + addDevLog({ + kind: 'debug', + category: 'playback', + message: `Opening track in ${target.appName}`, + details: { + trackId: track.id, + fileId: track.fileId, + mimeType: track.mimeType, + href: target.href, + appName: target.appName, + } + }) + window.location.href = target.href + }, [getPreferredExternalPlayer]) + + const fetchMediaInfo = useCallback(async (track: Track, signal?: AbortSignal): Promise => { + try { + let res = await fetch(track.url, { method: 'HEAD', mode: 'cors', signal }) + if (!res.ok) { + res = await fetch(track.url, { method: 'GET', mode: 'cors', headers: { Range: 'bytes=0-0' }, signal }) + } + + const mimeType = res.headers.get('content-type') || undefined + const isVideo = !!mimeType && (mimeType.startsWith('video/') || mimeType.includes('mpegurl')) + const mediaInfo = { mimeType, isVideo: isVideo || undefined } + mimeCacheRef.current[track.url] = mediaInfo + return mediaInfo + } catch (error: any) { + if (error?.name === 'AbortError') throw error + return {} + } + }, []) + + const resolveMediaInfo = useCallback(async (track: Track, signal?: AbortSignal): Promise => { + if (track.mimeType || track.isVideo !== undefined) { + return { mimeType: track.mimeType, isVideo: track.isVideo } + } + + const cachedInfo = mimeCacheRef.current[track.url] + if (cachedInfo) return cachedInfo + + if (VIDEO_URL_PATTERN.test(track.url)) { + const inferredInfo = { isVideo: true } + mimeCacheRef.current[track.url] = inferredInfo + return inferredInfo + } + + if (signal) { + return fetchMediaInfo(track, signal) + } + + const pendingRequest = mimeRequestCacheRef.current[track.url] + if (pendingRequest) return pendingRequest + + const request = (async () => { + try { + return await fetchMediaInfo(track) + } finally { + delete mimeRequestCacheRef.current[track.url] + } + })() + + mimeRequestCacheRef.current[track.url] = request + return request + }, [fetchMediaInfo]) + + const playNow = useCallback(async (track: Track) => { + try { playRequestAbortRef.current?.abort() } catch {} + const controller = new AbortController() + playRequestAbortRef.current = controller + + const cachedInfo = track.mimeType || track.isVideo !== undefined + ? { mimeType: track.mimeType, isVideo: track.isVideo } + : mimeCacheRef.current[track.url] || (VIDEO_URL_PATTERN.test(track.url) ? { isVideo: true } : undefined) + + let resolved = cachedInfo ? { ...track, ...cachedInfo } : track + + if (!cachedInfo) { + try { + const mediaInfo = await resolveMediaInfo(track, controller.signal) + if (controller.signal.aborted) return + resolved = { ...track, ...mediaInfo } + } catch (error: any) { + if (error?.name === 'AbortError') return + addDevLog({ + kind: 'debug', + category: 'playback', + message: 'Media info resolution failed', + details: { + trackId: track.id, + fileId: track.fileId, + name: error?.name, + message: error?.message ?? String(error), + } + }) + } + } + + openInPreferredExternalPlayer(resolved) + }, [openInPreferredExternalPlayer, resolveMediaInfo]) + + const toggleSidebar = useCallback(() => { + if (isDesktopLayout) { + setDesktopSidebarOpen((open) => !open) + return + } + setMobileSidebarOpen((open) => !open) + }, [isDesktopLayout]) + + const closeSidebar = useCallback(() => setMobileSidebarOpen(false), []) + const openSettings = useCallback(() => setActivePage('settings'), []) + const closeSettings = useCallback(() => setActivePage(lastBrowsePageRef.current), []) + const handleDevOverlayEnabledChange = useCallback((enabled: boolean) => { + setDevOverlayEnabled(enabled) + saveUiPreferences({ devOverlayEnabled: enabled }) + }, []) + const handleLibraryDisplayModeChange = useCallback((mode: 'grid' | 'table') => { + setLibraryDisplayMode(mode) + saveUiPreferences({ libraryDisplayMode: mode }) + }, []) + const handleSidebarNavigate = useCallback((id: string) => { + if (id === 'settings') setActivePage('settings') + else setActivePage(id as MediaSection) + + if (!isDesktopLayout) { + setMobileSidebarOpen(false) + } + }, [isDesktopLayout]) + + return ( + + + + +
+ + + + + + {activePage === 'settings' + ? ( + + + + ) + : ( + + )} + + + + {devOverlayEnabled ? ( + + + + ) : null} + + + + ) +} + +export default App diff --git a/src/api/hydrusClient.ts b/src/api/hydrusClient.ts new file mode 100644 index 0000000..72e7cc3 --- /dev/null +++ b/src/api/hydrusClient.ts @@ -0,0 +1,698 @@ +export type ServerConfig = { + id: string + name?: string + host: string + port?: string | number + apiKey?: string + ssl?: boolean + forceApiKeyInQuery?: boolean +} + +export type ConnectivityResult = { + ok: boolean + message: string + status?: number | null + searchOk?: boolean + rangeSupported?: boolean +} + +export type HydrusMediaInfo = { + mimeType?: string + isVideo?: boolean +} + +export type HydrusFileDetails = HydrusMediaInfo & { + fileId: number + extension?: string + sizeBytes?: number + width?: number + height?: number + durationMs?: number + tags: string[] +} + +export function makeId() { + return Date.now().toString() + '-' + Math.random().toString(36).slice(2, 9) +} + +export type HydrusSearchTag = string | HydrusSearchTags +export type HydrusSearchTags = HydrusSearchTag[] + +const SYSTEM_PREDICATE_PATTERN = /^(system:[^<>!=]+?)\s*(<=|>=|!=|=|<|>)\s*(.+)$/i +const SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g + +function createAbortError() { + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + +export class HydrusClient { + cfg: ServerConfig + + constructor(cfg: Partial = {}) { + this.cfg = { + id: (cfg.id as string) || makeId(), + name: cfg.name || '', + host: cfg.host || '', + port: cfg.port, + apiKey: cfg.apiKey, + ssl: !!cfg.ssl, + forceApiKeyInQuery: !!cfg.forceApiKeyInQuery + } + } + + baseUrl(): string { + if (!this.cfg.host) throw new Error('Hydrus host not defined') + let url = this.cfg.host.trim().replace(/\/+$/, '') + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = (this.cfg.ssl ? 'https://' : 'http://') + url + } + try { + const parsed = new URL(url) + if (this.cfg.port && !parsed.port) { + parsed.port = String(this.cfg.port) + } + // preserve any configured pathname (e.g., if Hydrus is hosted under /hydrus) + const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.replace(/\/$/, '') : '' + return parsed.origin + path + } catch (e) { + // fallback + return url + (this.cfg.port ? `:${this.cfg.port}` : '') + } + } + + getHeaders(includeApiKeyInHeader = true, includeContentType = false) { + const headers: Record = {} + if (includeContentType) headers['Content-Type'] = 'application/json' + if (this.cfg.apiKey && includeApiKeyInHeader) headers['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey + return headers + } + + private appendApiKeyToUrl(url: string) { + if (!this.cfg.apiKey) return url + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}Hydrus-Client-API-Access-Key=${encodeURIComponent(this.cfg.apiKey)}` + } + + private buildApiUrl(path: string, params: Record = {}, includeApiKeyInQuery = false) { + const url = new URL(`${this.baseUrl()}${path}`) + + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue + url.searchParams.set(key, String(value)) + } + + if (includeApiKeyInQuery && this.cfg.apiKey) { + url.searchParams.set('Hydrus-Client-API-Access-Key', this.cfg.apiKey) + } + + return url.toString() + } + + private async fetchWithAuthRetry(url: string, init: RequestInit = {}) { + const requestInit: RequestInit = { mode: 'cors', ...init } + let response = await fetch(url, requestInit) + + if ((response.status === 401 || response.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) { + response = await fetch(this.appendApiKeyToUrl(url), { + ...requestInit, + headers: undefined, + }) + } + + return response + } + + private async getFileMetadataPayload(fileId: number, signal?: AbortSignal) { + const url = this.buildApiUrl('/get_files/file_metadata', { file_id: fileId }, this.cfg.forceApiKeyInQuery ?? false) + const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false)) + const res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal }) + + if (res.status === 404) { + console.warn('[HydrusClient] getFileMetadata 404', { url, status: res.status }) + return null + } + + if (!res.ok) { + console.warn('[HydrusClient] getFileMetadata Response Error', { status: res.status, statusText: res.statusText }) + return null + } + + return res.json().catch(() => null) + } + + private getFileMetadataEntry(data: any, fileId: number) { + if (!data || typeof data !== 'object') return null + + if (data.file_metadata && typeof data.file_metadata === 'object' && !Array.isArray(data.file_metadata)) { + const direct = data.file_metadata[String(fileId)] + if (direct && typeof direct === 'object') return direct + } + + if (Array.isArray(data.file_metadata)) { + const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId)) + if (found && typeof found === 'object') return found + } + + if (String((data as any).file_id) === String(fileId)) return data + return null + } + + private normalizeMimeType(value: string): string | undefined { + const normalized = value.trim().toLowerCase() + if (!normalized) return undefined + + if (normalized.includes('mpegurl') || normalized.includes('m3u8')) return 'application/vnd.apple.mpegurl' + + const directMimeMatch = normalized.match(/(?:video|audio|application)\/[a-z0-9.+-]+/) + if (directMimeMatch) return directMimeMatch[0] + + const knownMimeMap: Array<[string, string]> = [ + ['quicktime', 'video/quicktime'], + ['matroska', 'video/x-matroska'], + ['mkv', 'video/x-matroska'], + ['mp4', normalized.includes('audio') ? 'audio/mp4' : 'video/mp4'], + ['webm', normalized.includes('audio') ? 'audio/webm' : 'video/webm'], + ['mpeg', normalized.includes('audio') ? 'audio/mpeg' : 'video/mpeg'], + ['avi', 'video/x-msvideo'], + ['wmv', 'video/x-ms-wmv'], + ['mov', 'video/quicktime'], + ['ogg', normalized.includes('video') ? 'video/ogg' : 'audio/ogg'], + ['mp3', 'audio/mpeg'], + ['m4a', 'audio/mp4'], + ['aac', 'audio/aac'], + ['flac', 'audio/flac'], + ['wav', 'audio/wav'], + ] + + const match = knownMimeMap.find(([token]) => normalized.includes(token)) + return match ? match[1] : undefined + } + + private extractMediaInfoFromMetadata(data: any, fileId: number): HydrusMediaInfo { + const metadata = this.getFileMetadataEntry(data, fileId) || data + if (!metadata || typeof metadata !== 'object') return {} + + let width = 0 + let height = 0 + let frameCount = 0 + let hasDuration = false + const mimeCandidates: string[] = [] + + const visit = (value: any, keyHint = '') => { + if (value == null) return + + if (typeof value === 'string') { + const lowerKey = keyHint.toLowerCase() + if (lowerKey.includes('mime') || lowerKey.includes('filetype') || lowerKey.includes('container') || lowerKey.includes('format')) { + mimeCandidates.push(value) + } + return + } + + if (typeof value === 'number') { + const lowerKey = keyHint.toLowerCase() + if (lowerKey === 'width') width = Math.max(width, value) + else if (lowerKey === 'height') height = Math.max(height, value) + else if (lowerKey.includes('frame')) frameCount = Math.max(frameCount, value) + else if (lowerKey.includes('duration')) hasDuration = hasDuration || value > 0 + return + } + + if (typeof value === 'boolean') { + if (keyHint.toLowerCase().includes('video') && value) mimeCandidates.push('video') + if (keyHint.toLowerCase().includes('audio') && value) mimeCandidates.push('audio') + return + } + + if (Array.isArray(value)) { + for (const item of value) visit(item, keyHint) + return + } + + if (typeof value === 'object') { + for (const [childKey, childValue] of Object.entries(value)) { + visit(childValue, childKey) + } + } + } + + visit(metadata) + + for (const candidate of mimeCandidates) { + const mimeType = this.normalizeMimeType(candidate) + if (mimeType) { + return { + mimeType, + isVideo: mimeType.startsWith('video/') || mimeType === 'application/vnd.apple.mpegurl' + } + } + const lower = candidate.toLowerCase() + if (lower.includes('video')) return { isVideo: true } + if (lower.includes('audio')) return { isVideo: false } + } + + if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return { isVideo: true } + if (hasDuration) return { isVideo: false } + + return {} + } + + private extractTagsFromMetadata(data: any, fileId: number): string[] { + if (!data) return [] + + try { + if (data.file_metadata && typeof data.file_metadata === 'object' && data.file_metadata[String(fileId)]) { + const meta = data.file_metadata[String(fileId)] + if (Array.isArray(meta.tags)) return meta.tags + + if (meta.service_keys_to_tags && typeof meta.service_keys_to_tags === 'object') { + const merged: string[] = [] + for (const value of Object.values(meta.service_keys_to_tags)) { + if (Array.isArray(value)) merged.push(...value) + else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags) + } + if (merged.length) return Array.from(new Set(merged)) + } + + if (meta.service_names_to_tags && typeof meta.service_names_to_tags === 'object') { + const merged: string[] = [] + for (const value of Object.values(meta.service_names_to_tags)) { + if (Array.isArray(value)) merged.push(...value) + else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags) + } + if (merged.length) return Array.from(new Set(merged)) + } + } + + if (Array.isArray(data.tags)) return data.tags + if (Array.isArray(data.file_tags)) return data.file_tags + + if (Array.isArray(data.file_metadata)) { + const found = data.file_metadata.find((item: any) => String(item?.file_id) === String(fileId)) + if (found) { + if (Array.isArray(found.tags)) return found.tags + if (found.service_keys_to_tags && typeof found.service_keys_to_tags === 'object') { + const merged: string[] = [] + for (const value of Object.values(found.service_keys_to_tags)) { + if (Array.isArray(value)) merged.push(...value) + else if (value && typeof value === 'object' && Array.isArray((value as any).tags)) merged.push(...(value as any).tags) + } + if (merged.length) return Array.from(new Set(merged)) + } + } + } + + const foundArrays: string[][] = [] + const walk = (obj: any) => { + if (!obj || typeof obj !== 'object') return + if (Array.isArray(obj)) { + if (obj.length > 0 && obj.every((item) => typeof item === 'string')) { + foundArrays.push(obj as string[]) + return + } + for (const entry of obj) walk(entry) + return + } + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + if (value.length > 0 && value.every((item) => typeof item === 'string')) foundArrays.push(value as string[]) + else for (const entry of value) walk(entry) + } else if (value && typeof value === 'object') { + walk(value) + } + } + } + + walk(data) + if (foundArrays.length) { + const flattened = ([] as string[]).concat(...foundArrays) + return Array.from(new Set(flattened)) + } + } catch { + // fall through + } + + return [] + } + + private extractFileDetailsFromMetadata(data: any, fileId: number): HydrusFileDetails { + const metadata = this.getFileMetadataEntry(data, fileId) || data || {} + const mediaInfo = this.extractMediaInfoFromMetadata(data, fileId) + const tags = this.extractTagsFromMetadata(data, fileId) + + let extension: string | undefined + let sizeBytes: number | undefined + let width: number | undefined + let height: number | undefined + let durationMs: number | undefined + + const visit = (value: any, keyHint = '') => { + if (value == null) return + const lowerKey = keyHint.toLowerCase() + + if (typeof value === 'string') { + if (!extension && (lowerKey === 'ext' || lowerKey === 'extension')) { + extension = value.replace(/^\./, '').trim().toLowerCase() || undefined + } + + if (!extension && (lowerKey.includes('filename') || lowerKey === 'name')) { + const match = value.match(/\.([a-z0-9]{1,10})$/i) + if (match) extension = match[1].toLowerCase() + } + + return + } + + if (typeof value === 'number') { + if ((lowerKey === 'size' || lowerKey === 'file_size' || lowerKey === 'num_bytes' || lowerKey === 'bytes') && value > 0) { + sizeBytes = Math.max(sizeBytes || 0, value) + } else if (lowerKey === 'width' && value > 0) { + width = Math.max(width || 0, value) + } else if (lowerKey === 'height' && value > 0) { + height = Math.max(height || 0, value) + } else if ((lowerKey.includes('duration') || lowerKey === 'ms') && value > 0) { + durationMs = Math.max(durationMs || 0, value) + } + return + } + + if (Array.isArray(value)) { + for (const item of value) visit(item, keyHint) + return + } + + if (typeof value === 'object') { + for (const [childKey, childValue] of Object.entries(value)) { + visit(childValue, childKey) + } + } + } + + visit(metadata) + + return { + fileId, + ...mediaInfo, + extension, + sizeBytes, + width, + height, + durationMs, + tags, + } + } + + private cleanSearchTag(value: HydrusSearchTag | undefined | null): HydrusSearchTag | null { + if (!value) return null + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + return /^system:/i.test(trimmed) ? this.normalizeSystemPredicate(trimmed) : trimmed + } + if (Array.isArray(value)) { + const cleaned = value + .map((item) => this.cleanSearchTag(item)) + .filter((item): item is HydrusSearchTag => item !== null) + return cleaned.length ? cleaned : null + } + return null + } + + private normalizeSystemPredicate(value: string) { + const trimmed = value.trim().replace(/\s+/g, ' ') + const match = trimmed.match(SYSTEM_PREDICATE_PATTERN) + if (!match) return trimmed + + const [, left, operator, right] = match + return `${left.trim()} ${operator} ${right.trim()}` + } + + private buildSearchTags(searchableText: string | HydrusSearchTags | undefined | null): HydrusSearchTags { + const tags: HydrusSearchTags = [] + if (!searchableText) return tags + + const seen = new Set() + + const pushValue = (value: HydrusSearchTag | undefined | null) => { + const cleaned = this.cleanSearchTag(value) + if (!cleaned) return + const key = JSON.stringify(cleaned) + if (seen.has(key)) return + seen.add(key) + tags.push(cleaned) + } + + if (typeof searchableText === 'string') { + const s = searchableText.trim() + if (!s) return tags + + if (/^system:/i.test(s)) { + pushValue(this.normalizeSystemPredicate(s)) + return tags + } + + const tokens = s.match(SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? [] + const wildcardize = (tok: string) => (tok.includes('*') ? tok : `*${tok}*`) + + if (tokens.length === 1) { + const t = tokens[0] + if (t.includes(':')) { + // user explicitly used a namespace or special token + pushValue(t) + } else { + // match either as a plain tag OR as part of title (substring) + pushValue([t, `title:${wildcardize(t)}`]) + } + } else { + // multi-token: try a full-phrase title match OR per-token matches + if (!s.includes(':')) { + pushValue([s, `title:${wildcardize(s)}`]) + } + + for (const t of tokens) { + if (t.includes(':')) pushValue(t) + else pushValue([t, `title:${wildcardize(t)}`]) + } + } + } else { + for (const tag of searchableText) { + pushValue(tag) + } + } + + return tags + } + + async searchFiles(searchableText: string | HydrusSearchTags | null = '', resultsPerPage = 20, signal?: AbortSignal): Promise { + // If server configured to prefer query param key, omit key from header + const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false)) + + const tagsArr: HydrusSearchTags = this.buildSearchTags(searchableText) + tagsArr.push(this.normalizeSystemPredicate(`system:limit=${resultsPerPage}`)) + + const url = this.buildApiUrl('/get_files/search_files', { + tags: JSON.stringify(tagsArr), + return_file_ids: 'true' + }, this.cfg.forceApiKeyInQuery ?? false) + + let res: Response + try { + res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal }) + } catch (err: any) { + if (err && err.name === 'AbortError') throw err + throw err + } + + if (res.status === 404) { + const text = await res.text().catch(() => '') + console.warn('[HydrusClient] searchFiles 404', { url, status: res.status, body: text }) + throw new Error(`Search failed (404): ${text ? text : 'Not Found'} (request: ${url}). Note: /get_files/search_files expects GET with a 'tags' query parameter. Avoid POST fallback as this endpoint may not accept POST.`) + } + + if (!res.ok) { + const text = await res.text().catch(() => '') + console.warn('[HydrusClient] searchFiles Response Error', { status: res.status, statusText: res.statusText, body: text }) + throw new Error(`Search failed (${res.status})${text ? ': ' + (text.length > 1000 ? text.slice(0, 1000) + '...' : text) : ''} (request: ${url})`) + } + + const data = await res.json() + if (Array.isArray(data)) return data as number[] + if (data && Array.isArray((data as any).file_ids)) return (data as any).file_ids + if (data && Array.isArray((data as any).results)) return (data as any).results + return [] + } + + getFileUrl(fileId: number, includeApiKeyInQuery = true) { + return this.buildApiUrl('/get_files/file', { file_id: fileId }, includeApiKeyInQuery) + } + + getThumbnailUrl(fileId: number, includeApiKeyInQuery = true) { + return this.buildApiUrl('/get_files/thumbnail', { file_id: fileId }, includeApiKeyInQuery) + } + + async testConnectivity(): Promise { + try { + // 1) Try a simple GET search with a small limit + const searchUrl = this.buildApiUrl('/get_files/search_files', { + tags: JSON.stringify([this.normalizeSystemPredicate('system:limit=1')]), + return_file_ids: 'true' + }, this.cfg.forceApiKeyInQuery ?? false) + + const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false)) + + let res = await fetch(searchUrl, { method: 'GET', headers, mode: 'cors' }) + + if ((res.status === 401 || res.status === 403) && this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) { + // Auth required; report it + return { ok: false, message: `Authentication required (status ${res.status})`, status: res.status } + } + + if (res.status === 404) { + const text = await res.text().catch(() => '') + return { ok: false, message: `Search endpoint not found (404): ${text ? text : 'No response body'}`, status: 404 } + } + + if (!res.ok) return { ok: false, message: `Search request failed (status ${res.status})`, status: res.status } + + const json = await res.json() + const fileId = Array.isArray(json) && json.length > 0 ? json[0] : json?.file_ids?.[0] ?? null + + const result: ConnectivityResult = { ok: true, message: 'Connected (search OK)', status: res.status, searchOk: true } + + // 2) If we have a file, test range requests for streaming/seek + if (fileId) { + try { + // Try with header first + const fileUrl = `${this.baseUrl()}/get_files/file?file_id=${fileId}` + const headers2: Record = {} + if (this.cfg.apiKey && !(this.cfg.forceApiKeyInQuery ?? false)) headers2['Hydrus-Client-API-Access-Key'] = this.cfg.apiKey + headers2['Range'] = 'bytes=0-0' + + const rres = await fetch(fileUrl, { method: 'GET', headers: headers2, mode: 'cors' }) + if (rres.status === 206 || (rres.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') { + result.rangeSupported = true + result.message += '; Range requests supported' + return result + } + + // Fallback: if header approach didn't yield 206, try query-param API key (useful for HTML audio tags) + if (this.cfg.apiKey) { + const qUrl = `${this.getFileUrl(fileId, true)}` + const rres2 = await fetch(qUrl, { method: 'GET', headers: { Range: 'bytes=0-0' }, mode: 'cors' }) + if (rres2.status === 206 || (rres2.headers.get('accept-ranges') || '').toLowerCase() === 'bytes') { + result.rangeSupported = true + result.message += '; Range requests supported (via query param)' + return result + } + } + + result.rangeSupported = false + result.message += '; Range request test failed (no 206)' + } catch (e: any) { + result.rangeSupported = false + result.message += `; Range test error: ${e?.message ?? String(e)}` + } + } else { + result.message += '; No files to test Range support' + } + + return result + } catch (err: any) { + const msg = err?.message ?? String(err) + // A TypeError commonly indicates network or CORS blocks in the browser + if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('TypeError')) { + return { ok: false, message: `Network or CORS error: ${msg}` } + } + return { ok: false, message: `Error: ${msg}` } + } + } + + async getFileTags(fileId: number, signal?: AbortSignal): Promise { + const data = await this.getFileMetadataPayload(fileId, signal) + if (!data) return [] + return this.extractTagsFromMetadata(data, fileId) + } + + async getFileMediaInfo(fileId: number, signal?: AbortSignal): Promise { + const data = await this.getFileMetadataPayload(fileId, signal) + if (!data) return {} + return this.extractMediaInfoFromMetadata(data, fileId) + } + + async getFileDetails(fileId: number, signal?: AbortSignal): Promise { + const data = await this.getFileMetadataPayload(fileId, signal) + if (!data) return { fileId, tags: [] } + return this.extractFileDetailsFromMetadata(data, fileId) + } + + async getFilesTags(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise> { + const out: Record = {} + if (!fileIds || fileIds.length === 0) return out + + let idx = 0 + const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => { + while (true) { + if (signal?.aborted) throw createAbortError() + + const i = idx + if (i >= fileIds.length) break + idx++ + const fid = fileIds[i] + try { + out[fid] = await this.getFileTags(fid, signal) + } catch (error: any) { + if (error?.name === 'AbortError') throw error + out[fid] = [] + } + } + }) + + await Promise.all(workers) + return out + } + + async getFilesMediaInfo(fileIds: number[], concurrency = 4, signal?: AbortSignal): Promise> { + const out: Record = {} + if (!fileIds || fileIds.length === 0) return out + + let idx = 0 + const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => { + while (true) { + if (signal?.aborted) throw createAbortError() + + const i = idx + if (i >= fileIds.length) break + idx++ + const fid = fileIds[i] + try { + out[fid] = await this.getFileMediaInfo(fid, signal) + } catch (error: any) { + if (error?.name === 'AbortError') throw error + out[fid] = {} + } + } + }) + + await Promise.all(workers) + return out + } +} + +export function extractTitleFromTags(tags: string[] | null | undefined): string | null { + if (!tags || tags.length === 0) return null + const candidates: string[] = tags.filter((t) => /^title:/i.test(t)) + if (candidates.length === 0) return null + const values = candidates + .map((t) => { + const m = t.match(/^title:(.*)$/i) + return m ? m[1].replace(/_/g, ' ').trim() : '' + }) + .filter(Boolean) + if (values.length === 0) return null + // prefer the longest (most descriptive) title + values.sort((a, b) => b.length - a.length) + return values[0] +} + diff --git a/src/appPreferences.ts b/src/appPreferences.ts new file mode 100644 index 0000000..47b0c75 --- /dev/null +++ b/src/appPreferences.ts @@ -0,0 +1,47 @@ +import type { MediaSection } from './types' + +export type UiPreferences = { + devOverlayEnabled: boolean + libraryQuery: string + libraryDisplayMode: 'grid' | 'table' + librarySortBy: string + librarySortDirection: 'asc' | 'desc' + librarySectionViews: Partial> +} + +const STORAGE_KEY = 'api_media_player_ui_preferences_v1' + +const DEFAULT_UI_PREFERENCES: UiPreferences = { + devOverlayEnabled: true, + libraryQuery: '', + libraryDisplayMode: 'grid', + librarySortBy: 'artist', + librarySortDirection: 'asc', + librarySectionViews: {}, +} + +export function loadUiPreferences(): UiPreferences { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_UI_PREFERENCES + const parsed = JSON.parse(raw) as Partial + return { + ...DEFAULT_UI_PREFERENCES, + ...parsed, + } + } catch { + return DEFAULT_UI_PREFERENCES + } +} + +export function saveUiPreferences(preferences: Partial) { + try { + const nextPreferences = { + ...loadUiPreferences(), + ...preferences, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences)) + } catch { + // ignore persistence failures + } +} \ No newline at end of file diff --git a/src/components/DevErrorPanel.tsx b/src/components/DevErrorPanel.tsx new file mode 100644 index 0000000..eff527f --- /dev/null +++ b/src/components/DevErrorPanel.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Box, Button, Paper, Typography, List, ListItem, ListItemText, IconButton, Chip } from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import { addDevLog, clearDevLogs, getDevLogs, subscribeDevLogs, type DevLogItem } from '../debugLog' + +function formatLogItem(log: DevLogItem) { + return [ + `${log.kind} - ${new Date(log.time).toLocaleString()}`, + log.category ? `category: ${log.category}` : null, + `message: ${log.message}`, + log.source ? `source: ${log.source}` : null, + log.stack ? `stack:\n${log.stack}` : null, + log.details ? `details:\n${log.details}` : null, + ].filter(Boolean).join('\n') +} + +export default function DevErrorPanel() { + const [logs, setLogs] = useState(() => getDevLogs()) + const [open, setOpen] = useState(false) + const errorCount = useMemo(() => logs.filter((item) => item.kind !== 'debug').length, [logs]) + + const copyLog = async (log: DevLogItem) => { + const payload = formatLogItem(log) + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(payload) + addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } }) + return + } + } catch (error: any) { + addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } }) + } + + try { + const textarea = document.createElement('textarea') + textarea.value = payload + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'fixed' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + textarea.remove() + addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id, fallback: true } }) + } catch (error: any) { + addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard fallback copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } }) + } + } + + useEffect(() => { + return subscribeDevLogs((items) => { + setLogs(items) + if (items.length > 0) setOpen(true) + }) + }, []) + + useEffect(() => { + const onError = (e: ErrorEvent) => { + try { + const item = { kind: 'error' as const, category: 'window', message: e.message || 'Error', stack: (e.error && (e.error.stack || e.error.message)) || undefined, source: e.filename ? `${e.filename}:${e.lineno}:${e.colno}` : undefined } + addDevLog(item) + // also log to console for developer convenience + // eslint-disable-next-line no-console + console.error('[DevErrorPanel] window.error', item) + } catch (_) {} + } + + const onRejection = (e: PromiseRejectionEvent) => { + try { + const reason: any = e.reason + const item = { kind: 'unhandledrejection' as const, category: 'window', message: (reason && (reason.message || String(reason))) || 'Unhandled rejection', stack: reason && reason.stack ? String(reason.stack) : undefined } + addDevLog(item) + // eslint-disable-next-line no-console + console.error('[DevErrorPanel] unhandledrejection', item) + } catch (_) {} + } + + window.addEventListener('error', onError) + window.addEventListener('unhandledrejection', onRejection) + + return () => { + window.removeEventListener('error', onError) + window.removeEventListener('unhandledrejection', onRejection) + } + }, []) + + if (!import.meta.env.DEV) return null + + return ( + + + + + setOpen((v) => !v)} /> + setOpen((v) => !v)} /> + Dev Log Panel + + + + setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}> + + + + + + {open && ( + + {logs.map((l) => ( + + + {l.message} + {l.category && {l.category}} + {l.source && {l.source}} + {l.stack && {l.stack}} + {l.details && {l.details}} + } /> + { void copyLog(l) }} sx={{ ml: 1, alignSelf: 'flex-start' }}> + + + + ))} + + )} + + + ) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..0fc4a46 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react' +import { AppBar, Toolbar, IconButton, Typography, Button, Menu, MenuItem, Box, Chip, TextField } from '@mui/material' +import MenuIcon from '@mui/icons-material/Menu' +import SettingsIcon from '@mui/icons-material/Settings' +import StorageIcon from '@mui/icons-material/Storage' +import { useServers } from '../context/ServersContext' + +type HeaderProps = { + onOpenSettings?: () => void + onToggleSidebar?: () => void + searchQuery?: string + onSearchQueryChange?: (value: string) => void + searchDisabled?: boolean +} + +export default function Header({ onOpenSettings, onToggleSidebar, searchQuery = '', onSearchQueryChange, searchDisabled = false }: HeaderProps) { + const { servers, activeServerId, setActiveServerId } = useServers() + const [anchor, setAnchor] = useState(null) + + const active = servers.find((s) => s.id === activeServerId) + const activeServerLabel = active ? active.name || active.host : 'No server configured' + + const handleOpen = (e: React.MouseEvent) => setAnchor(e.currentTarget) + const handleClose = () => setAnchor(null) + + return ( + + + onToggleSidebar && onToggleSidebar()} aria-label="menu" size="medium" sx={{ flexShrink: 0 }}> + + + + onSearchQueryChange && onSearchQueryChange(event.target.value)} + disabled={searchDisabled} + size="small" + placeholder="Search library" + sx={{ + flex: { xs: '1 1 calc(100% - 104px)', sm: 1 }, + minWidth: 0, + maxWidth: { sm: 520 }, + order: { xs: 1, sm: 0 }, + '& .MuiInputBase-input': { fontSize: { xs: 14, sm: 13 }, py: 0.9 }, + }} + /> + + onOpenSettings && onOpenSettings()} aria-label="settings" size="medium" sx={{ width: 40, height: 40, flexShrink: 0, order: { xs: 2, sm: 0 } }}> + + + + + + {active?.lastTest && active.lastTest.ok === false && ( + + )} + + {servers.length === 0 ? ( + No servers configured + ) : ( + servers.map((s) => ( + { + setActiveServerId(s.id) + handleClose() + }} + > + {s.name || s.host} + + )) + )} + { + handleClose() + onOpenSettings && onOpenSettings() + }} + > + Manage servers... + + + + + + ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..0029efb --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { Box, Drawer, List, ListItemButton, ListItemIcon, ListItemText, Typography, Divider } from '@mui/material' +import AudiotrackIcon from '@mui/icons-material/Audiotrack' +import MovieIcon from '@mui/icons-material/Movie' +import ImageIcon from '@mui/icons-material/Image' +import AppsIcon from '@mui/icons-material/Apps' +import LibraryMusicIcon from '@mui/icons-material/LibraryMusic' +import SettingsIcon from '@mui/icons-material/Settings' +import type { MediaSection } from '../types' + +export const drawerWidth = 240 + +type NavItem = { + id: MediaSection + label: string + icon: React.ReactNode +} + +const ITEMS: NavItem[] = [ + { id: 'all', label: 'All', icon: }, + { id: 'audio', label: 'Audio', icon: }, + { id: 'video', label: 'Video', icon: }, + { id: 'image', label: 'Image', icon: }, + { id: 'application', label: 'Applications', icon: }, +] + +export default function Sidebar({ + mobileOpen, + desktopOpen = true, + onMobileClose, + onNavigate, + activeId, +}: { + mobileOpen?: boolean + desktopOpen?: boolean + onMobileClose?: () => void + onNavigate?: (id: string) => void + activeId?: string +}) { + const handleNavigate = (id: string) => { + onNavigate?.(id) + onMobileClose?.() + } + + const content = ( + + + + H + + + Hydrus + + + + + + + {ITEMS.map((it) => ( + handleNavigate(it.id)} sx={{ borderRadius: 1, mb: 0.5 }}> + {it.icon} + + + ))} + + + + + + handleNavigate('settings')} sx={{ borderRadius: 1 }}> + + + + + + + + ) + + return ( + <> + theme.transitions.create('width', { + duration: theme.transitions.duration.standard, + easing: theme.transitions.easing.sharp, + }), + }} + > + theme.transitions.create('transform', { + duration: theme.transitions.duration.standard, + easing: theme.transitions.easing.sharp, + }), + }} + > + {content} + + + + {/* Temporary drawer for mobile */} + + {content} + + + ) +} diff --git a/src/context/ServersContext.tsx b/src/context/ServersContext.tsx new file mode 100644 index 0000000..49b03b9 --- /dev/null +++ b/src/context/ServersContext.tsx @@ -0,0 +1,144 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient' +import { HydrusClient, makeId } from '../api/hydrusClient' +import type { ServerSyncSummary } from '../types' + +const STORAGE_KEY = 'hydrus_servers_v1' +const ACTIVE_KEY = 'hydrus_active_id_v1' + +export type Server = ServerConfig & { + lastTest?: (ConnectivityResult & { timestamp: number }) | null + syncSummary?: ServerSyncSummary | null +} + +type ServersContextType = { + servers: Server[] + activeServerId: string | null + setActiveServerId: (id: string | null) => void + addServer: (s: Omit) => Server + updateServer: (id: string, patch: Partial) => void + removeServer: (id: string) => void + testServerById: (id: string) => Promise + testServerConfig: (cfg: Omit) => Promise +} + +const ServersContext = createContext(null) + +function loadServers(): Server[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + return JSON.parse(raw) as Server[] + } catch (e) { + return [] + } +} + +function saveServers(servers: Server[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(servers)) + } catch (e) { + // ignore + } +} + +export function ServersProvider({ children }: { children: React.ReactNode }) { + const [servers, setServers] = useState(() => loadServers()) + const [activeServerId, setActiveServerIdState] = useState(() => { + try { + return localStorage.getItem(ACTIVE_KEY) + } catch (e) { + return null + } + }) + + useEffect(() => saveServers(servers), [servers]) + + useEffect(() => { + if (!activeServerId && servers.length > 0) { + setActiveServerIdState(servers[0].id) + localStorage.setItem(ACTIVE_KEY, servers[0].id) + } + + // Seed a local server if none exist yet (user-provided default IP) + if (servers.length === 0) { + const seedHost = '192.168.1.128' + const seedPort = '45869' + const seedName = 'Local Hydrus (192.168.1.128)' + const id = makeId() + const srv: Server = { + id, + name: seedName, + host: seedHost, + port: seedPort, + apiKey: '', + ssl: false, + forceApiKeyInQuery: false, + lastTest: { ok: false, message: 'Unauthenticated (401). Add API key to test', status: 401, searchOk: false, rangeSupported: false, timestamp: Date.now() } + } + setServers([srv]) + setActiveServerIdState(id) + localStorage.setItem(STORAGE_KEY, JSON.stringify([srv])) + localStorage.setItem(ACTIVE_KEY, id) + } + }, []) + + const setActiveServerId = useCallback((id: string | null) => { + setActiveServerIdState(id) + if (id) localStorage.setItem(ACTIVE_KEY, id) + else localStorage.removeItem(ACTIVE_KEY) + }, []) + + const addServer = useCallback((s: Omit) => { + const id = makeId() + const srv: Server = { id, ...s, lastTest: null } + setServers((prev) => [...prev, srv]) + return srv + }, []) + + const updateServer = useCallback((id: string, patch: Partial) => { + setServers((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))) + }, []) + + const removeServer = useCallback((id: string) => { + setServers((prev) => prev.filter((s) => s.id !== id)) + if (activeServerId === id) setActiveServerId(null) + }, [activeServerId, setActiveServerId]) + + const testServerById = useCallback(async (id: string) => { + const server = servers.find((s) => s.id === id) + if (!server) return { ok: false, message: 'Server not found' } as ConnectivityResult + const client = new HydrusClient(server) + const res = await client.testConnectivity() + updateServer(id, { lastTest: { ...res, timestamp: Date.now() } }) + return res + }, [servers, updateServer]) + + const testServerConfig = useCallback(async (cfg: Omit) => { + const client = new HydrusClient(cfg as ServerConfig) + return client.testConnectivity() + }, []) + + const value = useMemo(() => ({ + servers, + activeServerId, + setActiveServerId, + addServer, + updateServer, + removeServer, + testServerById, + testServerConfig, + }), [servers, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig]) + + return ( + + {children} + + ) +} + +export function useServers() { + const ctx = useContext(ServersContext) + if (!ctx) throw new Error('useServers must be used within ServersProvider') + return ctx +} diff --git a/src/debugLog.ts b/src/debugLog.ts new file mode 100644 index 0000000..300dab7 --- /dev/null +++ b/src/debugLog.ts @@ -0,0 +1,62 @@ +import { makeId } from './api/hydrusClient' + +export type DevLogItem = { + id: string + time: number + kind: 'debug' | 'error' | 'unhandledrejection' + category?: string + message: string + stack?: string + source?: string + details?: string +} + +const MAX_LOGS = 200 +let logs: DevLogItem[] = [] +const listeners = new Set<(items: DevLogItem[]) => void>() + +function notify() { + for (const listener of listeners) listener(logs) +} + +function stringifyDetails(details: unknown) { + if (details == null) return undefined + if (typeof details === 'string') return details + + try { + return JSON.stringify(details, null, 2) + } catch { + return String(details) + } +} + +export function addDevLog(entry: Omit & { details?: unknown }) { + if (!import.meta.env.DEV) return + + const item: DevLogItem = { + id: makeId(), + time: Date.now(), + ...entry, + details: stringifyDetails(entry.details), + } + + logs = [item, ...logs].slice(0, MAX_LOGS) + notify() +} + +export function clearDevLogs() { + logs = [] + notify() +} + +export function getDevLogs() { + return logs +} + +export function subscribeDevLogs(listener: (items: DevLogItem[]) => void) { + listener(logs) + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} \ No newline at end of file diff --git a/src/libraryCache.ts b/src/libraryCache.ts new file mode 100644 index 0000000..6582ada --- /dev/null +++ b/src/libraryCache.ts @@ -0,0 +1,99 @@ +import type { Track } from './types' + +type CacheServerDescriptor = { + id: string + host: string + port?: string | number + ssl?: boolean +} + +type PersistedTrack = Omit + +type LibraryCacheRecord = { + cacheKey: string + updatedAt: number + tracks: PersistedTrack[] + searchCache: Record +} + +export type LibraryCacheSnapshot = { + tracks: PersistedTrack[] + searchCache: Record +} + +const DATABASE_NAME = 'api-mediaplayer-library-cache' +const DATABASE_VERSION = 1 +const STORE_NAME = 'snapshots' +const MAX_TRACKS = 5000 +const MAX_SEARCHES = 250 + +export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) { + return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|') +} + +function openLibraryCacheDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + + request.onupgradeneeded = () => { + const database = request.result + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' }) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('Failed to open library cache database')) + }) +} + +function withStore(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest) { + return new Promise((resolve, reject) => { + openLibraryCacheDatabase() + .then((database) => { + const transaction = database.transaction(STORE_NAME, mode) + const store = transaction.objectStore(STORE_NAME) + const request = handler(store) + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')) + + transaction.oncomplete = () => database.close() + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')) + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')) + }) + .catch(reject) + }) +} + +export async function loadLibraryCache(cacheKey: string): Promise { + if (!cacheKey || typeof indexedDB === 'undefined') return null + + const record = await withStore('readonly', (store) => store.get(cacheKey)) + if (!record) return null + + return { + tracks: Array.isArray(record.tracks) ? record.tracks : [], + searchCache: record.searchCache && typeof record.searchCache === 'object' ? record.searchCache : {}, + } +} + +export async function saveLibraryCache(cacheKey: string, tracks: Track[], searchCache: Record) { + if (!cacheKey || typeof indexedDB === 'undefined') return + + const trimmedTracks = tracks + .filter((track) => track.serverId && track.fileId != null && track.url) + .slice(-MAX_TRACKS) + .map(({ id: _id, ...track }) => track) + + const trimmedSearchCache = Object.fromEntries(Object.entries(searchCache).slice(-MAX_SEARCHES)) + + const record: LibraryCacheRecord = { + cacheKey, + updatedAt: Date.now(), + tracks: trimmedTracks, + searchCache: trimmedSearchCache, + } + + await withStore('readwrite', (store) => store.put(record)) +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5c94507 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.css' +import { registerServiceWorker, unregisterServiceWorker } from './serviceWorker' + +createRoot(document.getElementById('root') as HTMLElement).render( + + + +) + +if (import.meta.env.PROD) { + registerServiceWorker() +} else { + // In development, unregister service workers to avoid caching/fetch oddities while iterating + unregisterServiceWorker() +} diff --git a/src/mediaCache.ts b/src/mediaCache.ts new file mode 100644 index 0000000..261fb3c --- /dev/null +++ b/src/mediaCache.ts @@ -0,0 +1,27 @@ +const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v1' +const MAX_MEDIA_CACHE_ITEMS = 12 + +async function trimMediaCache(cache: Cache) { + const keys = await cache.keys() + if (keys.length <= MAX_MEDIA_CACHE_ITEMS) return + + const overflow = keys.length - MAX_MEDIA_CACHE_ITEMS + for (let index = 0; index < overflow; index += 1) { + await cache.delete(keys[index]) + } +} + +export async function cacheMediaFile(url: string) { + if (!url || typeof caches === 'undefined') return false + + const cache = await caches.open(MEDIA_CACHE_NAME) + const existing = await cache.match(url) + if (existing) return true + + const response = await fetch(url, { method: 'GET', mode: 'cors' }).catch(() => null) + if (!response || !response.ok || response.status !== 200) return false + + await cache.put(url, response.clone()) + await trimMediaCache(cache) + return true +} diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx new file mode 100644 index 0000000..215cddd --- /dev/null +++ b/src/pages/Library.tsx @@ -0,0 +1,1558 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, Typography, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { useServers } from '../context/ServersContext' +import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../api/hydrusClient' +import { loadUiPreferences, saveUiPreferences } from '../appPreferences' +import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache' +import type { HydrusSearchTags } from '../api/hydrusClient' +import type { MediaSection, Track } from '../types' + +const NO_IMAGE_DATA_URL = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='140'%3E%3Crect fill='%23eee' width='100%25' height='100%25'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='20'%3ENo Image%3C/text%3E%3C/svg%3E" +const RESULTS_PAGE_SIZE = 36 +const SEARCH_FETCH_LIMIT = RESULTS_PAGE_SIZE * 5 +const INITIAL_SYSTEM_FETCH_LIMIT = RESULTS_PAGE_SIZE * 10 +const SEARCH_TEXT_SCAN_LIMIT = 2000 +const LONG_PRESS_DELAY_MS = 420 + +type Props = { + mediaSection: MediaSection + onPlayNow: (track: Track) => void | Promise + query: string + onQueryChange: (value: string) => void + displayModePreference: 'grid' | 'table' +} + +type LibraryView = 'tracks' | 'albums' | 'artists' | 'text' | 'data' +type DisplayMode = 'grid' | 'table' +type SortDirection = 'asc' | 'desc' +type TrackSortField = 'title' | 'artist' | 'album' | 'server' | 'fileId' +type EntrySortField = 'name' | 'count' +type SortField = TrackSortField | EntrySortField + +type SortOption = { + id: SortField + label: string +} + +const SECTION_CONFIG: Record }> = { + all: { + label: 'All Files', + views: [ + { id: 'tracks', label: 'All Files' }, + ], + }, + audio: { + label: 'Audio', + systemPredicate: 'system:filetype = audio', + views: [ + { id: 'tracks', label: 'Tracks' }, + { id: 'albums', label: 'Albums' }, + { id: 'artists', label: 'Artists' }, + ], + }, + video: { + label: 'Video', + systemPredicate: 'system:filetype = video', + views: [ + { id: 'tracks', label: 'Videos' }, + { id: 'albums', label: 'Albums' }, + { id: 'artists', label: 'Artists' }, + ], + }, + image: { + label: 'Image', + systemPredicate: 'system:filetype = image', + views: [ + { id: 'tracks', label: 'Images' }, + { id: 'albums', label: 'Albums' }, + ], + }, + application: { + label: 'Applications', + systemPredicate: 'system:filetype = application', + views: [ + { id: 'tracks', label: 'Applications' }, + { id: 'text', label: 'Text' }, + { id: 'data', label: 'Data' }, + ], + }, +} + +type AlbumEntry = { + name: string + servers: { serverId: string; count: number; thumbnail?: string }[] + totalCount: number +} + +const TRACK_SORT_OPTIONS: SortOption[] = [ + { id: 'artist', label: 'Artist' }, + { id: 'album', label: 'Album' }, + { id: 'title', label: 'Title' }, + { id: 'server', label: 'Server' }, + { id: 'fileId', label: 'File ID' }, +] + +const ENTRY_SORT_OPTIONS: SortOption[] = [ + { id: 'name', label: 'Name' }, + { id: 'count', label: 'Items' }, +] + +function compareText(left?: string | null, right?: string | null) { + return (left || '').localeCompare((right || ''), undefined, { numeric: true, sensitivity: 'base' }) +} + +function getTrackDisplayTitle(track: Track) { + return track.title?.trim() || (track.fileId != null ? `File ${track.fileId}` : 'Untitled') +} + +function sortTracks(tracks: Track[], sortBy: TrackSortField, sortDirection: SortDirection) { + const direction = sortDirection === 'asc' ? 1 : -1 + + return [...tracks].sort((left, right) => { + let comparison = 0 + + switch (sortBy) { + case 'artist': + comparison = compareText(left.artist, right.artist) + if (comparison === 0) comparison = compareText(left.album, right.album) + if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) + break + case 'album': + comparison = compareText(left.album, right.album) + if (comparison === 0) comparison = compareText(left.artist, right.artist) + if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) + break + case 'server': + comparison = compareText(left.serverName, right.serverName) + if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) + break + case 'fileId': + comparison = (left.fileId || 0) - (right.fileId || 0) + if (comparison === 0) comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) + break + case 'title': + default: + comparison = compareText(getTrackDisplayTitle(left), getTrackDisplayTitle(right)) + if (comparison === 0) comparison = compareText(left.artist, right.artist) + break + } + + return comparison * direction + }) +} + +function sortEntries(entries: AlbumEntry[], sortBy: EntrySortField, sortDirection: SortDirection) { + const direction = sortDirection === 'asc' ? 1 : -1 + + return [...entries].sort((left, right) => { + let comparison = 0 + + if (sortBy === 'count') { + comparison = left.totalCount - right.totalCount + if (comparison === 0) comparison = compareText(left.name, right.name) + } else { + comparison = compareText(left.name, right.name) + if (comparison === 0) comparison = left.totalCount - right.totalCount + } + + return comparison * direction + }) +} + +function getDefaultSortField(view: LibraryView, hasArtistsView: boolean): SortField { + if (view === 'albums' || view === 'artists') return 'name' + return hasArtistsView ? 'artist' : 'title' +} + +function getStoredSectionView(section: MediaSection, views: Partial>) { + const candidate = views[section] + return SECTION_CONFIG[section].views.some((item) => item.id === candidate) ? candidate as LibraryView : 'tracks' +} + +function tokenizeSearchWords(value: string) { + return value + .toLocaleLowerCase() + .replace(/_/g, ' ') + .split(/[^a-z0-9]+/i) + .map((part) => part.trim()) + .filter(Boolean) +} + +function formatBytes(value?: number) { + if (!value || value <= 0) return null + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = value + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex += 1 + } + + return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}` +} + +function formatDuration(value?: number) { + if (!value || value <= 0) return null + const totalSeconds = Math.round(value >= 1000 ? value / 1000 : value) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` + } + + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + +function getTrackKindLabel(track: Track, fallbackLabel: string) { + if (track.mediaKind && track.mediaKind !== 'all') { + return SECTION_CONFIG[track.mediaKind]?.label || fallbackLabel + } + + const mimeType = (track.mimeType || '').toLowerCase() + if (track.isVideo || mimeType.startsWith('video/')) return 'Video' + if (mimeType.startsWith('audio/')) return 'Audio' + if (mimeType.startsWith('image/')) return 'Image' + if (mimeType.startsWith('text/')) return 'Text' + if (mimeType.startsWith('application/')) return 'Application' + return fallbackLabel +} + +export default function Library({ mediaSection, onPlayNow, query, onQueryChange, displayModePreference }: Props) { + const initialUiPreferences = useMemo(() => loadUiPreferences(), []) + const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial>, [initialUiPreferences]) + const [results, setResults] = useState([]) + const [albums, setAlbums] = useState([]) + const [artists, setArtists] = useState([]) + const [sectionViews, setSectionViews] = useState>>(initialSectionViews) + const [view, setView] = useState(() => getStoredSectionView(mediaSection, initialSectionViews)) + const [sortBy, setSortBy] = useState(initialUiPreferences.librarySortBy as SortField) + const [sortDirection, setSortDirection] = useState(initialUiPreferences.librarySortDirection) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [visibleCount, setVisibleCount] = useState(RESULTS_PAGE_SIZE) + const theme = useTheme() + const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) + + const { servers, updateServer } = useServers() + const hasServers = servers.length > 0 + const serverCacheKey = buildLibraryCacheKey(servers) + const serverSearchSignature = useMemo(() => servers.map((server) => [ + server.id, + server.name, + server.host, + server.port, + server.apiKey, + server.ssl, + server.forceApiKeyInQuery, + ].join('|')).join(','), [servers]) + const searchCacheRef = React.useRef>({}) + const trackCacheRef = React.useRef>({}) + const albumCacheRef = React.useRef>({}) + const artistCacheRef = React.useRef>({}) + const albumTracksRef = React.useRef>({}) + const searchAbortRef = useRef(null) + const playMetadataAbortRef = useRef(null) + const persistTimeoutRef = useRef(null) + const detailsAbortRef = useRef(null) + const longPressTimerRef = useRef(null) + const longPressTriggeredRef = useRef(false) + const [albumFilter, setAlbumFilter] = useState(null) + const [artistFilter, setArtistFilter] = useState(null) + const [detailsTrack, setDetailsTrack] = useState(null) + const [detailsData, setDetailsData] = useState(null) + const [detailsOpen, setDetailsOpen] = useState(false) + const [detailsLoading, setDetailsLoading] = useState(false) + const [detailsError, setDetailsError] = useState(null) + const syncingSectionViewRef = useRef(false) + const sectionConfig = SECTION_CONFIG[mediaSection] + const isAllSection = mediaSection === 'all' + const isTrackLikeView = view === 'tracks' || view === 'text' || view === 'data' + const hasAlbumsView = sectionConfig.views.some((item) => item.id === 'albums') + const hasArtistsView = sectionConfig.views.some((item) => item.id === 'artists') + const effectiveDisplayMode: DisplayMode = isAllSection ? 'table' : displayModePreference + const sortOptions = useMemo(() => isTrackLikeView ? TRACK_SORT_OPTIONS : ENTRY_SORT_OPTIONS, [isTrackLikeView]) + const queryWords = useMemo(() => tokenizeSearchWords(query), [query]) + + function extractNamespaceValue(tags: string[] | null | undefined, ns: string): string | null { + if (!tags || !Array.isArray(tags)) return null + const prefix = `${ns.toLowerCase()}:` + let bestValue = '' + + for (const tag of tags) { + if (typeof tag !== 'string') continue + if (!tag.toLowerCase().startsWith(prefix)) continue + + const value = tag.slice(prefix.length).replace(/_/g, ' ').trim() + if (value.length > bestValue.length) bestValue = value + } + + return bestValue || null + } + + function escapeHydrusSearchValue(value: string) { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + } + + function buildNamespaceSearch(ns: 'album' | 'artist', value: string, matchMode: 'contains' | 'exact' = 'contains') { + const trimmed = value.trim() + if (!trimmed) return `${ns}:*` + const escapedValue = escapeHydrusSearchValue(trimmed) + return matchMode === 'exact' ? `${ns}:"${escapedValue}"` : `${ns}:"*${escapedValue}*"` + } + + function normalizeNamespaceValue(value?: string | null) { + return (value || '').trim().toLocaleLowerCase() + } + + function matchesMediaSection(track: Track, section: MediaSection) { + if (section === 'all') return true + if (track.mediaKind) return track.mediaKind === section + + const mimeType = (track.mimeType || '').toLowerCase() + if (section === 'video') return !!track.isVideo || mimeType.startsWith('video/') + if (section === 'audio') return (!track.isVideo && mimeType.startsWith('audio/')) || mimeType.includes('mpegurl') + if (section === 'image') return mimeType.startsWith('image/') + if (section === 'application') return mimeType.startsWith('application/') || mimeType.startsWith('text/') + return false + } + + function getApplicationSubtype(track: Track) { + const mimeType = (track.mimeType || '').toLowerCase() + if (!mimeType) return 'application' as const + + if ( + mimeType.startsWith('text/') || + mimeType.includes('pdf') || + mimeType.includes('rtf') || + mimeType.includes('epub') || + mimeType.includes('msword') || + mimeType.includes('wordprocessingml') || + mimeType.includes('presentation') + ) { + return 'text' as const + } + + if ( + mimeType.includes('json') || + mimeType.includes('xml') || + mimeType.includes('yaml') || + mimeType.includes('csv') || + mimeType.includes('spreadsheet') || + mimeType.includes('excel') || + mimeType.includes('sqlite') + ) { + return 'data' as const + } + + return 'application' as const + } + + function filterTracksForView(tracks: Track[], currentView: LibraryView) { + const sectionTracks = filterTracksForSection(tracks, mediaSection) + if (mediaSection !== 'application') return sectionTracks + if (currentView === 'text') return sectionTracks.filter((track) => getApplicationSubtype(track) === 'text') + if (currentView === 'data') return sectionTracks.filter((track) => getApplicationSubtype(track) === 'data') + return sectionTracks + } + + function filterTracksForSection(tracks: Track[], section: MediaSection) { + return tracks.filter((track) => matchesMediaSection(track, section)) + } + + function buildSectionSearchTags(baseQuery: string, namespace?: 'album' | 'artist'): HydrusSearchTags { + const tags: HydrusSearchTags = [] + const trimmedQuery = baseQuery.trim() + + if (sectionConfig.systemPredicate) { + tags.push(sectionConfig.systemPredicate) + } + + if (namespace) tags.push(buildNamespaceSearch(namespace, trimmedQuery)) + + return tags + } + + function matchesTrackSearch(track: Track, words: string[]) { + if (words.length === 0) return true + + const searchableValues = [ + getTrackDisplayTitle(track), + ...(track.tags || []), + ] + + const searchableWords = new Set(searchableValues.flatMap((value) => tokenizeSearchWords(value))) + return words.every((word) => searchableWords.has(word)) + } + + function getTrackExtension(track: Track, details?: HydrusFileDetails | null) { + if (details?.extension) return details.extension + const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i) + return match ? match[1].toLowerCase() : undefined + } + + function getTrackCacheKey(serverId?: string, fileId?: number) { + return serverId && fileId != null ? `${serverId}:${fileId}` : '' + } + + function cacheTracks(tracks: Track[]) { + for (const track of tracks) { + const cacheKey = getTrackCacheKey(track.serverId, track.fileId) + if (!cacheKey) continue + const existing = trackCacheRef.current[cacheKey] + trackCacheRef.current[cacheKey] = existing ? { ...existing, ...track } : { ...track } + } + + schedulePersistLibraryCache() + } + + function addTrackToAlbumCache(track: Track) { + if (!track.album) return + const cacheKey = getTrackCacheKey(track.serverId, track.fileId) + if (!cacheKey) return + + const currentTracks = albumTracksRef.current[track.album] || [] + const existingIndex = currentTracks.findIndex((candidate) => getTrackCacheKey(candidate.serverId, candidate.fileId) === cacheKey) + + if (existingIndex >= 0) currentTracks[existingIndex] = { ...currentTracks[existingIndex], ...track } + else currentTracks.push(track) + + albumTracksRef.current[track.album] = currentTracks + } + + function schedulePersistLibraryCache() { + if (!serverCacheKey || typeof window === 'undefined') return + + if (persistTimeoutRef.current) { + window.clearTimeout(persistTimeoutRef.current) + } + + persistTimeoutRef.current = window.setTimeout(() => { + persistTimeoutRef.current = null + void saveLibraryCache(serverCacheKey, Object.values(trackCacheRef.current), searchCacheRef.current) + }, 250) + } + + function buildNamespaceEntriesFromTracks(tracks: Track[], ns: 'album' | 'artist') { + const entries: Record = {} + + for (const track of tracks) { + const name = ns === 'album' ? track.album : track.artist + if (!name) continue + + if (!entries[name]) { + entries[name] = { name, servers: [], totalCount: 0 } + } + + const entry = entries[name] + entry.totalCount += 1 + + const serverId = track.serverId || 'unknown' + let serverEntry = entry.servers.find((server) => server.serverId === serverId) + if (!serverEntry) { + serverEntry = { serverId, count: 0, thumbnail: track.thumbnail } + entry.servers.push(serverEntry) + } + + serverEntry.count += 1 + if (!serverEntry.thumbnail && track.thumbnail) serverEntry.thumbnail = track.thumbnail + } + + return Object.values(entries).sort((a, b) => a.name.localeCompare(b.name)) + } + + function buildArtistGroupsFromTracks(tracks: Track[]) { + const groupsMap: Record = {} + const seen = new Set() + + for (const track of tracks) { + const trackKey = getTrackCacheKey(track.serverId, track.fileId) || `${track.serverName || 'local'}:${track.url}` + if (seen.has(trackKey)) continue + seen.add(trackKey) + + const artistName = track.artist || 'Unknown' + if (!groupsMap[artistName]) groupsMap[artistName] = [] + groupsMap[artistName].push(track) + } + + const groups = Object.entries(groupsMap).map(([name, artistTracks]) => ({ name, tracks: [...artistTracks] })) + for (const group of groups) { + group.tracks.sort((a, b) => { + const aAlbum = (a.album || '').toLowerCase() + const bAlbum = (b.album || '').toLowerCase() + if (aAlbum && bAlbum && aAlbum !== bAlbum) return aAlbum.localeCompare(bAlbum) + return (a.title || '').toLowerCase().localeCompare((b.title || '').toLowerCase()) + }) + } + + return groups.sort((a, b) => a.name.localeCompare(b.name)) + } + + useEffect(() => { + let cancelled = false + + const restoreCachedLibrary = async () => { + if (!hasServers || !serverCacheKey) { + trackCacheRef.current = {} + searchCacheRef.current = {} + albumTracksRef.current = {} + return + } + + try { + const snapshot = await loadLibraryCache(serverCacheKey) + if (cancelled || !snapshot) return + + trackCacheRef.current = {} + albumTracksRef.current = {} + searchCacheRef.current = snapshot.searchCache || {} + + let localCounter = Date.now() + const hydratedTracks = (snapshot.tracks || []).map((track) => ({ ...track, id: ++localCounter })) + for (const track of hydratedTracks) { + const cacheKey = getTrackCacheKey(track.serverId, track.fileId) + if (cacheKey) trackCacheRef.current[cacheKey] = track + addTrackToAlbumCache(track) + } + + const sectionTracks = filterTracksForView(hydratedTracks, view) + const cachedAlbums = buildNamespaceEntriesFromTracks(sectionTracks, 'album') + const cachedArtists = buildNamespaceEntriesFromTracks(sectionTracks, 'artist') + if (!cancelled) { + setAlbums(cachedAlbums) + setArtists(cachedArtists) + + if ((view === 'tracks' || view === 'text' || view === 'data') && !query.trim() && !albumFilter && !artistFilter) { + setResults(sectionTracks) + } + } + } catch { + // ignore cache restore failures and fall back to live Hydrus data + } + } + + void restoreCachedLibrary() + + return () => { + cancelled = true + } + }, [albumFilter, artistFilter, hasServers, mediaSection, query, serverCacheKey, view]) + + + useEffect(() => { + const controller = new AbortController() + searchAbortRef.current = controller + + const performSearch = async () => { + if (!hasServers) { + setResults([]) + setError(null) + setLoading(false) + return + } + + const searchQuery = query.trim() + const isDefaultSectionSearch = view === 'tracks' && searchQuery.length === 0 + const shouldUseLocalTextSearch = isTrackLikeView && searchQuery.length > 0 + const cachedSectionTracks = filterTracksForView(Object.values(trackCacheRef.current), view) + + setLoading(true) + setError(null) + setResults((view === 'tracks' || view === 'text' || view === 'data') && !albumFilter && !artistFilter ? cachedSectionTracks : []) + + if (view === 'tracks') { + setAlbums(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'album')) + setArtists(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'artist')) + } else if (view === 'albums') { + albumCacheRef.current = {} + setAlbums(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'album')) + } else { + artistCacheRef.current = {} + setArtists(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'artist')) + } + + const cache = searchCacheRef.current + + let pending = servers.length + let localCounter = Date.now() + let successfulSearchCount = 0 + const failedMessages: string[] = [] + + const finishOne = () => { + pending -= 1 + if (pending <= 0 && !controller.signal.aborted) { + setError(successfulSearchCount === 0 && failedMessages.length > 0 ? failedMessages[0] : null) + setLoading(false) + } + } + + for (const s of servers) { + ;(async () => { + const client = new HydrusClient(s) + try { + if (view === 'tracks' || view === 'text' || view === 'data') { + const trackSearchTags = shouldUseLocalTextSearch + ? (sectionConfig.systemPredicate ? [sectionConfig.systemPredicate] : []) + : buildSectionSearchTags(searchQuery) + const cacheKey = `${s.id}|${mediaSection}|${view}|${JSON.stringify(trackSearchTags)}` + let ids: number[] = [] + + if (cache[cacheKey]) ids = cache[cacheKey] + else { + const pageSize = shouldUseLocalTextSearch || isAllSection ? SEARCH_TEXT_SCAN_LIMIT : isDefaultSectionSearch ? INITIAL_SYSTEM_FETCH_LIMIT : SEARCH_FETCH_LIMIT + ids = await client.searchFiles(trackSearchTags, pageSize, controller.signal) + cache[cacheKey] = ids + schedulePersistLibraryCache() + } + + if (controller.signal.aborted) return + successfulSearchCount += 1 + + try { updateServer && updateServer(s.id, { lastTest: { ok: true, message: 'Search OK', status: null, searchOk: true, rangeSupported: s.lastTest?.rangeSupported ?? false, timestamp: Date.now() } }) } catch {} + + if (!ids || ids.length === 0) return + + const newTracks: Track[] = [] + const cachedTracks: Track[] = [] + const baseTracksByFileId = new Map() + for (const fid of ids) { + localCounter += 1 + const cachedTrack = trackCacheRef.current[getTrackCacheKey(s.id, fid)] + const track = cachedTrack + ? { ...cachedTrack, id: localCounter, fileId: fid, serverId: s.id, serverName: s.name || s.host, url: client.getFileUrl(fid), thumbnail: cachedTrack.thumbnail || client.getThumbnailUrl(fid), mediaKind: cachedTrack.mediaKind || mediaSection } + : { id: localCounter, fileId: fid, serverId: s.id, serverName: s.name || s.host, title: '', url: client.getFileUrl(fid), thumbnail: client.getThumbnailUrl(fid), mediaKind: mediaSection } + newTracks.push(track) + baseTracksByFileId.set(fid, track) + if (cachedTrack?.tags?.length) { + cachedTracks.push(track) + addTrackToAlbumCache(track) + } + } + + cacheTracks(newTracks) + + setResults((prev) => { + const seen = new Set(prev.map((r) => `${r.serverId}:${r.fileId}`)) + const combined = [...prev] + for (const t of newTracks) { + const key = `${t.serverId}:${t.fileId}` + if (!seen.has(key)) { + combined.push(t) + seen.add(key) + } + } + return combined + }) + + // Eagerly fetch tags for a larger subset when doing a system:everything seed so we can build artist/album groups + try { + const subsetIds = ids.filter((fid) => !trackCacheRef.current[getTrackCacheKey(s.id, fid)]?.tags?.length) + if (subsetIds.length) { + const [tagMap, mediaInfoMap] = await Promise.all([ + client.getFilesTags(subsetIds, isDefaultSectionSearch ? 8 : 4, controller.signal), + mediaSection === 'application' ? client.getFilesMediaInfo(subsetIds, 6, controller.signal) : Promise.resolve({} as Record), + ]) + if (controller.signal.aborted) return + + const enrichedTrackMetadata = new Map>() + const enrichedTracks: Track[] = [] + + for (const fid of subsetIds) { + const baseTrack = baseTracksByFileId.get(fid) + if (!baseTrack) continue + + const tags = tagMap[fid] || [] + const title = extractTitleFromTags(tags) + const isVideo = /\.(m3u8|mp4|webm|ogg|mov)$/i.test(baseTrack.url) + const artist = extractNamespaceValue(tags, 'artist') + const album = extractNamespaceValue(tags, 'album') + const mediaInfo = mediaInfoMap[fid] || {} + + const metadata: Partial = { + title: title || '', + tags: tags.length ? tags : undefined, + isVideo: mediaInfo.isVideo ?? (isVideo || undefined), + artist: artist || undefined, + album: album || undefined, + mimeType: mediaInfo.mimeType, + mediaKind: mediaSection, + } + + const enrichedTrack: Track = { + ...baseTrack, + ...metadata + } + + enrichedTrackMetadata.set(fid, metadata) + enrichedTracks.push(enrichedTrack) + addTrackToAlbumCache(enrichedTrack) + } + + cacheTracks(enrichedTracks) + + if (enrichedTrackMetadata.size > 0) { + setResults((prev) => prev.map((track) => { + if (track.serverId !== s.id || track.fileId == null) return track + const metadata = enrichedTrackMetadata.get(track.fileId) + return metadata ? { ...track, ...metadata } : track + })) + } + + setAlbums(buildNamespaceEntriesFromTracks(filterTracksForView(Object.values(trackCacheRef.current), view), 'album')) + } + } catch (e) { + // per-server metadata fetch failed — ignore but continue + } + } else if (view === 'albums' || view === 'artists') { + const ns = view === 'albums' ? 'album' : 'artist' + const tagQuery = buildSectionSearchTags(query, ns) + const cacheKey = `${s.id}|${mediaSection}|${ns}|${JSON.stringify(tagQuery)}` + + let ids: number[] = [] + if (cache[cacheKey]) ids = cache[cacheKey] + else { + ids = await client.searchFiles(tagQuery, SEARCH_FETCH_LIMIT, controller.signal) + cache[cacheKey] = ids + schedulePersistLibraryCache() + } + + if (controller.signal.aborted) return + successfulSearchCount += 1 + + try { updateServer && updateServer(s.id, { lastTest: { ok: true, message: 'Search OK', status: null, searchOk: true, rangeSupported: s.lastTest?.rangeSupported ?? false, timestamp: Date.now() } }) } catch {} + + if (!ids || ids.length === 0) return + + const subsetIds = ids + try { + const tagMap = await client.getFilesTags(subsetIds, 6, controller.signal) + if (controller.signal.aborted) return + + const localAlbums: Record = {} + const hydratedTracks: Track[] = [] + const mediaInfoMap = mediaSection === 'application' ? await client.getFilesMediaInfo(subsetIds, 6, controller.signal) : {} + + for (const fid of subsetIds) { + const tags = tagMap[fid] || [] + const title = extractTitleFromTags(tags) || '' + const album = extractNamespaceValue(tags, 'album') || undefined + const artist = extractNamespaceValue(tags, 'artist') || undefined + const baseTrack: Track = { + id: Date.now() + hydratedTracks.length, + fileId: fid, + serverId: s.id, + serverName: s.name || s.host, + title, + artist, + album, + tags: tags.length ? tags : undefined, + url: client.getFileUrl(fid), + thumbnail: client.getThumbnailUrl(fid), + mimeType: mediaInfoMap[fid]?.mimeType, + isVideo: mediaInfoMap[fid]?.isVideo, + mediaKind: mediaSection, + } + + hydratedTracks.push(baseTrack) + addTrackToAlbumCache(baseTrack) + + for (const t of tags) { + const m = t.match(new RegExp(`^${ns}:(.*)$`, 'i')) + if (!m) continue + const name = m[1].replace(/_/g, ' ').trim() + if (!name) continue + if (!localAlbums[name]) localAlbums[name] = { count: 0, thumbnail: undefined } + localAlbums[name].count += 1 + if (!localAlbums[name].thumbnail) { + const url = baseTrack.thumbnail + if (url) localAlbums[name].thumbnail = url + } + } + } + + cacheTracks(hydratedTracks) + + // merge into global album/artist map + const targetRef = view === 'albums' ? albumCacheRef.current : artistCacheRef.current + for (const [name, info] of Object.entries(localAlbums)) { + const existing = targetRef[name] + if (existing) { + // add server-specific info + existing.servers = existing.servers || [] + existing.servers.push({ serverId: s.id, count: info.count, thumbnail: info.thumbnail }) + existing.totalCount = (existing.totalCount || 0) + info.count + } else { + targetRef[name] = { name, servers: [{ serverId: s.id, count: info.count, thumbnail: info.thumbnail }], totalCount: info.count } + } + } + + // update UI progressively + const arr = Object.values(view === 'albums' ? albumCacheRef.current : artistCacheRef.current) + arr.sort((a, b) => a.name.localeCompare(b.name)) + if (view === 'albums') setAlbums(arr) + else setArtists(arr) + + } catch (e) { + // ignore per-server failures + } + } + + } catch (err: any) { + if (controller.signal.aborted) return + const msg = err?.message ?? String(err) + failedMessages.push(msg) + try { updateServer && updateServer(s.id, { lastTest: { ok: false, message: msg, status: null, searchOk: false, rangeSupported: false, timestamp: Date.now() } }) } catch {} + } finally { + finishOne() + } + })() + } + } + + const t = setTimeout(performSearch, 200) + return () => { + clearTimeout(t) + if (searchAbortRef.current === controller) searchAbortRef.current = null + try { controller.abort() } catch {} + } + }, [ + mediaSection, + query, + hasServers, + serverSearchSignature, + updateServer, + view + ]) + + useEffect(() => { + syncingSectionViewRef.current = true + setView(getStoredSectionView(mediaSection, sectionViews)) + }, [mediaSection]) + + useEffect(() => { + if (syncingSectionViewRef.current) { + syncingSectionViewRef.current = false + return + } + + setSectionViews((prev) => { + if (prev[mediaSection] === view) return prev + const next = { ...prev, [mediaSection]: view } + saveUiPreferences({ librarySectionViews: next }) + return next + }) + }, [mediaSection, view]) + + useEffect(() => { + if (!sectionConfig.views.some((item) => item.id === view)) { + setView('tracks') + } + }, [sectionConfig.views, view]) + + useEffect(() => { + if (!sortOptions.some((option) => option.id === sortBy)) { + setSortBy(getDefaultSortField(view, hasArtistsView)) + } + }, [hasArtistsView, sortBy, sortOptions, view]) + + useEffect(() => { + saveUiPreferences({ + librarySortBy: sortBy, + librarySortDirection: sortDirection, + }) + }, [sortBy, sortDirection]) + + useEffect(() => { + if (view !== 'tracks') { + setAlbumFilter(null) + setArtistFilter(null) + } + }, [view]) + + useEffect(() => { + if (query.trim().length > 0) { + setAlbumFilter(null) + setArtistFilter(null) + } + }, [query]) + + useEffect(() => { + setVisibleCount(RESULTS_PAGE_SIZE) + }, [view, query, albumFilter, artistFilter]) + + useEffect(() => { + return () => { + try { searchAbortRef.current?.abort() } catch {} + try { playMetadataAbortRef.current?.abort() } catch {} + try { detailsAbortRef.current?.abort() } catch {} + if (persistTimeoutRef.current && typeof window !== 'undefined') { + window.clearTimeout(persistTimeoutRef.current) + } + if (longPressTimerRef.current && typeof window !== 'undefined') { + window.clearTimeout(longPressTimerRef.current) + } + } + }, []) + + const handlePlayNow = async (track: Track) => { + if (!track?.url) return + + try { searchAbortRef.current?.abort() } catch {} + setLoading(false) + + try { playMetadataAbortRef.current?.abort() } catch {} + const metadataController = new AbortController() + playMetadataAbortRef.current = metadataController + + // If the title is a generic fallback like 'File 123', try to fetch tags for a better title + const needsTitle = /^File\s+\d+$/i.test(track.title) + const needsMediaInfo = track.isVideo === undefined || !track.mimeType + + if (needsTitle || needsMediaInfo) { + try { + const server = servers.find((s) => s.id === track.serverId) + if (server && track.fileId != null) { + const client = new HydrusClient(server) + let nextTrack = track + + if (needsTitle) { + const tags = await client.getFileTags(track.fileId, metadataController.signal) + const title = extractTitleFromTags(tags) + if (title) { + nextTrack = { ...nextTrack, title } + } + } + + if (needsMediaInfo) { + const mediaInfo = await client.getFileMediaInfo(track.fileId, metadataController.signal) + if (mediaInfo.mimeType || mediaInfo.isVideo !== undefined) { + nextTrack = { ...nextTrack, ...mediaInfo } + } + } + + if (nextTrack !== track) { + setResults((prev) => prev.map((r) => (r.id === track.id ? { ...r, ...nextTrack } : r))) + cacheTracks([nextTrack]) + addTrackToAlbumCache(nextTrack) + track = nextTrack + } + } + } catch (e: any) { + if (e?.name === 'AbortError') return + // ignore + } + } + + await onPlayNow(track) + } + + const clearLongPressTimer = () => { + if (longPressTimerRef.current && typeof window !== 'undefined') { + window.clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + + const closeDetails = () => { + clearLongPressTimer() + try { detailsAbortRef.current?.abort() } catch {} + setDetailsOpen(false) + setDetailsLoading(false) + setDetailsError(null) + } + + const handleDetailsTagSearch = (tag: string) => { + setAlbumFilter(null) + setArtistFilter(null) + setView('tracks') + onQueryChange(tag) + closeDetails() + } + + const openTrackDetails = async (track: Track) => { + setDetailsTrack(track) + setDetailsOpen(true) + setDetailsLoading(true) + setDetailsError(null) + setDetailsData(null) + + try { detailsAbortRef.current?.abort() } catch {} + const controller = new AbortController() + detailsAbortRef.current = controller + + try { + const server = servers.find((candidate) => candidate.id === track.serverId) + if (!server || track.fileId == null) { + setDetailsData({ + fileId: track.fileId ?? 0, + mimeType: track.mimeType, + isVideo: track.isVideo, + extension: getTrackExtension(track), + tags: track.tags || [], + }) + setDetailsLoading(false) + return + } + + const client = new HydrusClient(server) + const details = await client.getFileDetails(track.fileId, controller.signal) + if (controller.signal.aborted) return + setDetailsData(details) + setDetailsLoading(false) + } catch (error: any) { + if (error?.name === 'AbortError') return + setDetailsError(error?.message ?? String(error)) + setDetailsLoading(false) + } + } + + const handleTrackActivate = async (track: Track) => { + if (longPressTriggeredRef.current) { + longPressTriggeredRef.current = false + return + } + + await handlePlayNow(track) + } + + const getTrackInteractionProps = (track: Track) => ({ + onClick: () => { void handleTrackActivate(track) }, + onContextMenu: (event: React.MouseEvent) => { + event.preventDefault() + longPressTriggeredRef.current = true + void openTrackDetails(track) + }, + onTouchStart: () => { + if (!isCompactTableLayout || typeof window === 'undefined') return + clearLongPressTimer() + longPressTriggeredRef.current = false + longPressTimerRef.current = window.setTimeout(() => { + longPressTriggeredRef.current = true + void openTrackDetails(track) + }, LONG_PRESS_DELAY_MS) + }, + onTouchEnd: clearLongPressTimer, + onTouchCancel: clearLongPressTimer, + onTouchMove: clearLongPressTimer, + }) + + const handleImageError = (event: React.SyntheticEvent) => { + event.currentTarget.src = NO_IMAGE_DATA_URL + } + + const getDisplayTitle = (track?: Track) => (track ? getTrackDisplayTitle(track) : '') + + const getVisibleArtistGroups = (groups: Array<{ name: string; tracks: Track[] }>, limit: number) => { + const visibleGroups: Array<{ name: string; tracks: Track[] }> = [] + let remaining = limit + + for (const group of groups) { + if (remaining <= 0) break + + const visibleTracks = group.tracks.slice(0, remaining) + if (visibleTracks.length) { + visibleGroups.push({ name: group.name, tracks: visibleTracks }) + remaining -= visibleTracks.length + } + } + + return visibleGroups + } + + const filteredAlbumTracks = useMemo(() => { + if (!albumFilter) return [] + return filterTracksForSection(albumTracksRef.current[albumFilter] || results.filter((track) => track.album === albumFilter), mediaSection) + }, [albumFilter, mediaSection, results]) + + const filteredArtistTracks = useMemo(() => { + if (!artistFilter) return [] + return filterTracksForSection(Object.values(trackCacheRef.current), mediaSection) + .filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(artistFilter)) + }, [artistFilter, mediaSection, results]) + + const currentTrackResults = useMemo(() => { + const sectionTracks = filterTracksForView(results, view) + if (!isTrackLikeView || queryWords.length === 0) return sectionTracks + return sectionTracks.filter((track) => matchesTrackSearch(track, queryWords)) + }, [isTrackLikeView, queryWords, results, view]) + const baseArtistGroups = useMemo(() => { + if (!hasArtistsView || albumFilter || artistFilter) return [] + return buildArtistGroupsFromTracks(currentTrackResults) + }, [albumFilter, artistFilter, currentTrackResults, hasArtistsView]) + const sortedFilteredAlbumTracks = useMemo(() => sortTracks(filteredAlbumTracks, sortBy as TrackSortField, sortDirection), [filteredAlbumTracks, sortBy, sortDirection]) + const sortedFilteredArtistTracks = useMemo(() => sortTracks(filteredArtistTracks, sortBy as TrackSortField, sortDirection), [filteredArtistTracks, sortBy, sortDirection]) + const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection]) + const sortedArtistGroups = useMemo(() => { + if (!hasArtistsView) return [] + + return [...baseArtistGroups] + .map((group) => ({ name: group.name, tracks: sortTracks(group.tracks, 'title', sortDirection) })) + .sort((left, right) => compareText(left.name, right.name) * (sortDirection === 'asc' ? 1 : -1)) + }, [baseArtistGroups, hasArtistsView, sortDirection]) + const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection]) + const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection]) + const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !albumFilter && !artistFilter && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist' + const visibleFilteredAlbumTracks = useMemo(() => sortedFilteredAlbumTracks.slice(0, visibleCount), [sortedFilteredAlbumTracks, visibleCount]) + const visibleFilteredArtistTracks = useMemo(() => sortedFilteredArtistTracks.slice(0, visibleCount), [sortedFilteredArtistTracks, visibleCount]) + const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups]) + const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount]) + const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount]) + const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount]) + const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount]) + + const canLoadMore = isTrackLikeView + ? albumFilter + ? visibleCount < sortedFilteredAlbumTracks.length + : artistFilter + ? visibleCount < sortedFilteredArtistTracks.length + : shouldShowGroupedArtists + ? visibleCount < totalArtistGroupTracks + : visibleCount < sortedCurrentTrackResults.length + : view === 'albums' + ? visibleCount < sortedAlbums.length + : visibleCount < sortedArtists.length + + const renderTrackGrid = ( + tracks: Array, + options?: { showAlbum?: boolean; showFileIdFallback?: boolean } + ) => ( + + {tracks.map((track, idx) => { + const isVideoTrack = !!track?.isVideo || /\.(m3u8|mp4|webm|ogg|mov)$/i.test(track?.url || '') + + return ( + + + + + + + + + + + {track?.isVideo &&
Video
} + {track?.serverName &&
{track.serverName}
} +
+ + + {getDisplayTitle(track) ? {getDisplayTitle(track)} : null} + {options?.showAlbum && track?.album && {track.album}} + {options?.showFileIdFallback && track && !getDisplayTitle(track) && File {track.fileId}} + +
+ ) + })} +
+ ) + + const openAlbumEntry = (name: string) => { + if (filterTracksForSection(albumTracksRef.current[name] || [], mediaSection).length > 0) { + setArtistFilter(null) + onQueryChange('') + setAlbumFilter(name) + setView('tracks') + return + } + + setView('tracks') + onQueryChange(buildNamespaceSearch('album', name, 'contains')) + } + + const openArtistEntry = (name: string) => { + const cachedArtistTracks = filterTracksForSection(Object.values(trackCacheRef.current), mediaSection) + .filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(name)) + + if (cachedArtistTracks.length > 0) { + setAlbumFilter(null) + onQueryChange('') + setArtistFilter(name) + setView('tracks') + return + } + + setView('tracks') + onQueryChange(buildNamespaceSearch('artist', name, 'contains')) + } + + const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => ( + isCompactTableLayout ? ( + + {tracks.map((track) => ( + + + + + {getDisplayTitle(track)} + + {track.artist || track.album || track.serverName || getTrackKindLabel(track, sectionConfig.label)} + + + {[options?.showAlbum || track.album ? (track.album || null) : null, track.serverName || null, track.fileId != null ? `#${track.fileId}` : null].filter(Boolean).join(' • ')} + + + + + ))} + + ) : ( + + + + + Track + Artist + Album + Server + ID + + + + {tracks.map((track) => ( + + + + + + {getDisplayTitle(track)} + + {getTrackKindLabel(track, sectionConfig.label)} + {options?.showFileIdFallback && !track.title?.trim() && track.fileId != null ? ` • File ${track.fileId}` : ''} + + + + + {track.artist || '—'} + {options?.showAlbum || track.album ? (track.album || '—') : '—'} + {track.serverName || '—'} + {track.fileId ?? '—'} + + ))} + +
+
+ ) + ) + + const renderEntryTable = (entries: AlbumEntry[], kind: 'album' | 'artist') => ( + isCompactTableLayout ? ( + + {entries.map((entry) => ( + { if (kind === 'album') openAlbumEntry(entry.name); else openArtistEntry(entry.name) }} sx={{ border: '1px solid rgba(255,255,255,0.08)', bgcolor: 'background.paper', backgroundImage: 'none', borderRadius: 2, px: 1.25, py: 1.1, cursor: 'pointer' }}> + + {entry.servers[0]?.thumbnail ? ( + + ) : ( + + {kind === 'album' ? 'AL' : 'AR'} + + )} + + {entry.name} + + {`${entry.totalCount} items • ${entry.servers.length} server${entry.servers.length === 1 ? '' : 's'}`} + + + + + ))} + + ) : ( + + + + + {kind === 'album' ? 'Album' : 'Artist'} + Items + Servers + + + + {entries.map((entry) => ( + { if (kind === 'album') openAlbumEntry(entry.name); else openArtistEntry(entry.name) }} sx={{ cursor: 'pointer', '& .MuiTableCell-root': { borderColor: 'rgba(255,255,255,0.08)' } }}> + + + {entry.servers[0]?.thumbnail ? ( + + ) : ( + + {kind === 'album' ? 'AL' : 'AR'} + + )} + {entry.name} + + + {entry.totalCount} + {entry.servers.length} + + ))} + +
+
+ ) + ) + + return ( + + theme.zIndex.appBar - 1, + backgroundColor: 'background.paper', + borderBottom: '1px solid rgba(255,255,255,0.08)', + boxShadow: '0 8px 18px rgba(0,0,0,0.18)', + py: 1, + mb: 2, + }} + > + {sectionConfig.views.length > 1 ? ( + setView(v)} variant="scrollable" allowScrollButtonsMobile> + {sectionConfig.views.map((item) => )} + + ) : ( + + {sectionConfig.label} + + )} + + + {!hasServers && ( + + No Hydrus server selected. Use the settings (top-right) to add one. + + )} + + {error && ( + + {error} + + )} + + + + Sort + + + + Order + + + + + + + {(view === 'tracks' || view === 'text' || view === 'data') && ( + <> + {albumFilter ? ( + + + Album: {albumFilter} + + + {hasAlbumsView && } + + + + {effectiveDisplayMode === 'table' + ? renderTrackTable(visibleFilteredAlbumTracks, { showAlbum: true }) + : renderTrackGrid(visibleFilteredAlbumTracks, { showAlbum: true })} + + ) : artistFilter ? ( + + + Artist: {artistFilter} + + + {hasArtistsView && } + + + + {effectiveDisplayMode === 'table' + ? renderTrackTable(visibleFilteredArtistTracks, { showAlbum: true }) + : renderTrackGrid(visibleFilteredArtistTracks, { showAlbum: true })} + + ) : shouldShowGroupedArtists ? ( + visibleArtistGroups.map((g) => { + const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean))) + return ( + + {g.name} + + {albumNames.length > 0 && ( + + {albumNames.map((a) => { + const count = g.tracks.filter((t: any) => (t.album || '') === a).length + return { setArtistFilter(null); onQueryChange(''); setAlbumFilter(a); setView('tracks') }} /> + })} + + )} + + {renderTrackGrid(g.tracks, { showAlbum: true })} + + ) + }) + ) : ( + effectiveDisplayMode === 'table' + ? renderTrackTable(visibleResults, { showFileIdFallback: true }) + : renderTrackGrid(visibleResults, { showFileIdFallback: true }) + )} + + )} + + {view === 'albums' && hasAlbumsView && ( + effectiveDisplayMode === 'table' + ? renderEntryTable(visibleAlbums, 'album') + : ( + + {visibleAlbums.map((al) => ( + + + openAlbumEntry(al.name)}> + {al.servers[0]?.thumbnail ? ( + + ) : ( + {al.name} + )} + + + {al.name} + + + + + + + ))} + + ) + )} + + {view === 'artists' && hasArtistsView && ( + effectiveDisplayMode === 'table' + ? renderEntryTable(visibleArtists, 'artist') + : ( + + {visibleArtists.map((ar) => ( + + + openArtistEntry(ar.name)}> + {ar.servers[0]?.thumbnail ? ( + + ) : ( + {ar.name} + )} + + + {ar.name} + + + + + + + ))} + + ) + )} + + + {canLoadMore && !loading && ( + + + + )} + + {loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && !albumFilter && !artistFilter && ( + + Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}... + + )} + + {!loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && ( + + No results found. + + )} + + {loading && view !== 'tracks' && ((view === 'albums' && hasAlbumsView && albums.length === 0) || (view === 'artists' && hasArtistsView && artists.length === 0)) && ( + + Loading {view}... + + )} + + {view !== 'tracks' && ((view === 'albums' && hasAlbumsView && albums.length === 0) || (view === 'artists' && hasArtistsView && artists.length === 0)) && !loading && ( + + No results found. + + )} + + + {detailsTrack ? getDisplayTitle(detailsTrack) : 'Item details'} + + {detailsLoading && Loading item details...} + {!detailsLoading && detailsError && {detailsError}} + {!detailsLoading && !detailsError && detailsTrack && ( + + + {detailsTrack.serverName && } + {detailsData?.mimeType && } + {getTrackExtension(detailsTrack, detailsData) && } + {detailsData?.isVideo !== undefined && } + + + + + File ID + {detailsTrack.fileId ?? 'Unknown'} + + + Size + {formatBytes(detailsData?.sizeBytes) || 'Unknown'} + + + Resolution + {detailsData?.width && detailsData?.height ? `${detailsData.width} x ${detailsData.height}` : 'Unknown'} + + + Duration + {formatDuration(detailsData?.durationMs) || 'Unknown'} + + + + + Tags + + {(detailsData?.tags || detailsTrack.tags || []).length > 0 + ? (detailsData?.tags || detailsTrack.tags || []).map((tag) => handleDetailsTagSearch(tag)} />) + : No tags available.} + + + + )} + + + + + + + ) +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..35b1325 --- /dev/null +++ b/src/pages/SettingsPage.tsx @@ -0,0 +1,381 @@ +import React, { useEffect, useState } from 'react' +import { + Box, + FormControl, + InputLabel, + Grid, + Typography, + List, + ListItem, + ListItemText, + Button, + IconButton, + TextField, + Switch, + FormControlLabel, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + MenuItem, + Select +} from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import DeleteIcon from '@mui/icons-material/Delete' +import PlayArrowIcon from '@mui/icons-material/PlayArrow' +import EditIcon from '@mui/icons-material/Edit' +import AddIcon from '@mui/icons-material/Add' +import CloudDownloadIcon from '@mui/icons-material/CloudDownload' +import type { Server } from '../context/ServersContext' +import { useServers } from '../context/ServersContext' +import { HydrusClient, extractTitleFromTags } from '../api/hydrusClient' +import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache' +import type { MediaSection, ServerSyncSummary, Track } from '../types' + +const SYNC_SECTION_LIMIT = 2000 +const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false } +const SYNC_SECTIONS: Array<{ id: MediaSection; label: string; predicate: string }> = [ + { id: 'audio', label: 'Audio', predicate: 'system:filetype = audio' }, + { id: 'video', label: 'Video', predicate: 'system:filetype = video' }, + { id: 'image', label: 'Image', predicate: 'system:filetype = image' }, + { id: 'application', label: 'Applications', predicate: 'system:filetype = application' }, +] + +type SettingsPageProps = { + onClose?: () => void + devOverlayEnabled: boolean + onDevOverlayEnabledChange: (enabled: boolean) => void + libraryDisplayMode: 'grid' | 'table' + onLibraryDisplayModeChange: (mode: 'grid' | 'table') => void +} + +export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, libraryDisplayMode, onLibraryDisplayModeChange }: SettingsPageProps) { + const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers() + const [editing, setEditing] = useState(null) + const [form, setForm] = useState>(DEFAULT_SERVER_FORM) + const [testing, setTesting] = useState(false) + const [syncingServerId, setSyncingServerId] = useState(null) + const [lastTest, setLastTest] = useState(null) + const [detailsOpen, setDetailsOpen] = useState(false) + const [detailsText, setDetailsText] = useState(null) + + const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => { + if (!tags || !Array.isArray(tags)) return null + const prefix = `${ns.toLowerCase()}:` + const values = tags + .filter((tag) => typeof tag === 'string' && tag.toLowerCase().startsWith(prefix)) + .map((tag) => tag.slice(prefix.length).replace(/_/g, ' ').trim()) + .filter(Boolean) + return values.sort((a, b) => b.length - a.length)[0] || null + } + + const buildTrackCacheKey = (serverId?: string, fileId?: number) => (serverId && fileId != null ? `${serverId}:${fileId}` : '') + + useEffect(() => { + setEditing(null) + setForm(DEFAULT_SERVER_FORM) + setLastTest(null) + setDetailsText(null) + setDetailsOpen(false) + }, []) + + const startAdd = () => { + setEditing(null) + setForm(DEFAULT_SERVER_FORM) + } + + const startEdit = (s: Server) => { + setEditing(s) + setForm({ name: s.name || '', host: s.host, port: s.port, apiKey: s.apiKey || '', ssl: !!s.ssl, forceApiKeyInQuery: !!s.forceApiKeyInQuery }) + setLastTest(s.lastTest ? `${s.lastTest.message} (${new Date(s.lastTest.timestamp).toLocaleString()})` : null) + } + + const handleSave = () => { + if (editing) { + updateServer(editing.id, form) + } else { + const srv = addServer(form) + setActiveServerId(srv.id) + } + onClose && onClose() + } + + const handleDelete = (s: Server) => { + if (confirm(`Delete server ${s.name || s.host}?`)) { + removeServer(s.id) + } + } + + const handleTestExisting = async (s: Server) => { + setTesting(true) + try { + const res = await testServerById(s.id) + setLastTest(`${res.message}`) + } catch (e: any) { + setLastTest(`Error: ${e?.message ?? String(e)}`) + } finally { + setTesting(false) + } + } + + const handleTestForm = async () => { + setTesting(true) + try { + const res = await testServerConfig(form) + setLastTest(`${res.message}`) + } catch (e: any) { + setLastTest(`Error: ${e?.message ?? String(e)}`) + } finally { + setTesting(false) + } + } + + const handleSyncServer = async (server: Server) => { + setSyncingServerId(server.id) + + try { + const client = new HydrusClient(server) + const cacheKey = buildLibraryCacheKey(servers) + const snapshot = await loadLibraryCache(cacheKey) + const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) } + const mergedTrackMap: Record = {} + + let localCounter = Date.now() + for (const track of snapshot?.tracks ?? []) { + const hydratedTrack: Track = { ...track, id: ++localCounter } + const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId) + if (key) mergedTrackMap[key] = hydratedTrack + } + + const counts: ServerSyncSummary['counts'] = {} + + for (const section of SYNC_SECTIONS) { + const searchTags = [section.predicate] + const ids = await client.searchFiles(searchTags, SYNC_SECTION_LIMIT) + counts[section.id] = ids.length + mergedSearchCache[`${server.id}|${section.id}|tracks|${JSON.stringify(searchTags)}`] = ids + + if (ids.length === 0) continue + + const tagMap = await client.getFilesTags(ids, 8) + const mediaInfoMap = section.id === 'application' ? await client.getFilesMediaInfo(ids, 6) : {} + for (const fileId of ids) { + const tags = tagMap[fileId] || [] + const key = buildTrackCacheKey(server.id, fileId) + if (!key) continue + + mergedTrackMap[key] = { + id: ++localCounter, + fileId, + serverId: server.id, + serverName: server.name || server.host, + title: extractTitleFromTags(tags) || '', + artist: extractNamespaceValue(tags, 'artist') || undefined, + album: extractNamespaceValue(tags, 'album') || undefined, + tags: tags.length ? tags : undefined, + url: client.getFileUrl(fileId), + thumbnail: client.getThumbnailUrl(fileId), + mimeType: mediaInfoMap[fileId]?.mimeType, + isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined), + mediaKind: section.id, + } + } + } + + await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache) + + const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0) + const summary: ServerSyncSummary = { + updatedAt: Date.now(), + total, + counts, + message: `Synced ${total} cached items`, + } + + updateServer(server.id, { syncSummary: summary }) + setLastTest(summary.message ?? `Synced ${total} cached items`) + } catch (error: any) { + const message = error?.message ?? String(error) + updateServer(server.id, { + syncSummary: { + updatedAt: Date.now(), + total: 0, + counts: {}, + message: `Sync failed: ${message}`, + }, + }) + setLastTest(`Sync failed: ${message}`) + } finally { + setSyncingServerId(null) + } + } + + return ( + + + + + onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}> + + + Hydrus Servers + + + + {import.meta.env.DEV && ( + + Library display + + Choose the default Library layout so browsing controls can stay compact. + + + Display + + + + Developer tools + + Control development-only UI that can get in the way on smaller screens. + + onDevOverlayEnabledChange(event.target.checked)} />} + label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'} + sx={{ alignItems: 'flex-start', m: 0 }} + /> + + )} + + {!import.meta.env.DEV && ( + + Library display + + Choose the default Library layout so browsing controls can stay compact. + + + Display + + + + )} + + + + + + Configured servers + + + + + {servers.length === 0 && No servers configured yet} + {servers.map((s) => ( + + + + + + + startEdit(s)}> + + + handleDelete(s)}> + + + + + {s.lastTest && s.lastTest.message && } + {s.syncSummary?.message && } + {s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => ( + + ))} + {s.forceApiKeyInQuery && } + + {s.syncSummary?.updatedAt && ( + + Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()} + + )} + + ))} + + + + + + + {editing ? 'Edit server' : 'Add new server'} + + setForm({ ...form, name: e.target.value })} fullWidth /> + setForm({ ...form, host: e.target.value })} fullWidth /> + setForm({ ...form, port: e.target.value })} fullWidth /> + setForm({ ...form, apiKey: e.target.value })} fullWidth /> + setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" /> + setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" /> + + + + + + + + {lastTest && ( + + Last test: {lastTest} + + + + + )} + + setDetailsOpen(false)} fullWidth maxWidth="md"> + Connection details + + {detailsText} + + + + + + + + + + + + ) +} diff --git a/src/sampleData.ts b/src/sampleData.ts new file mode 100644 index 0000000..44f4700 --- /dev/null +++ b/src/sampleData.ts @@ -0,0 +1,5 @@ +import type { Track } from './types' + +// No bundled sample tracks by default +export const SAMPLE_TRACKS: Track[] = [] + diff --git a/src/serviceWorker.ts b/src/serviceWorker.ts new file mode 100644 index 0000000..b6c4cf6 --- /dev/null +++ b/src/serviceWorker.ts @@ -0,0 +1,16 @@ +export function registerServiceWorker() { + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .then((reg) => console.log('Service worker registered:', reg)) + .catch((err) => console.log('Service worker registration failed:', err)) + }) + } +} + +export function unregisterServiceWorker() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.getRegistrations().then((regs) => regs.forEach((r) => r.unregister())) + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..fcbbb49 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,55 @@ +/* Navidrome-like dark theme variables */ +:root { + --app-bg: #0f1113; + --surface: #151617; + --muted: rgba(255,255,255,0.6); + --accent: #1db954; + --sidebar-width: 240px; +} + +body { + margin: 0; + font-family: Roboto, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + background: var(--app-bg); + color: #e6eef3; +} + +#root { height: 100vh } + +/* Sidebar */ +.sidebar { + width: var(--sidebar-width); + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); + border-right: 1px solid rgba(255,255,255,0.03); +} + +/* Library grid (album/artist style) */ +.library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; padding: 12px; } +.card-media { position: relative; overflow: hidden; border-radius: 6px; background: #0b0b0b; } +.card-media img { width: 100%; height: 160px; object-fit: cover; display: block; } +.card-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 120ms ease-in-out; background: linear-gradient(180deg, rgba(0,0,0,0.0), rgba(0,0,0,0.32)); } +.card-media:hover .card-overlay, +.card-media:focus-within .card-overlay { opacity: 1; } +.card-badge { position: absolute; top: 8px; left: 8px; background: rgba(0,0,0,0.5); color: #fff; padding: 2px 6px; border-radius: 12px; font-size: 12px; } + +/* Play icon in overlay */ +.card-overlay .play-button { background: rgba(0,0,0,0.6); border-radius: 999px; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; } + +/* card server badge variant */ +.card-badge.server-badge { left: auto; right: 8px; background: rgba(255,255,255,0.04); } + +/* Touch-friendly helpers and mobile layout */ +.touch-target { min-height: 48px; min-width: 48px; padding: 8px 12px; } + +@media (max-width:600px) { + .app-content { padding: 4px !important; } + .touch-target { min-height: 56px; padding: 10px 16px; font-size: 1rem; } + .library-grid { grid-template-columns: repeat(auto-fill, minmax(136px, 1fr)); gap: 10px; padding: 8px 0; } + .card-media img { height: 140px; object-fit: cover; } +} + +@media (hover: none), (pointer: coarse) { + .card-overlay { opacity: 1; background: linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.38)); } + .card-overlay .play-button { width: 52px; height: 52px; } +} + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f535df1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,26 @@ +export type MediaSection = 'all' | 'audio' | 'video' | 'image' | 'application' + +export type ServerSyncSummary = { + updatedAt: number + total: number + counts: Partial> + message?: string +} + +export type Track = { + id: number // local unique id used by the UI + fileId?: number // original Hydrus file id (per-server) + serverId?: string // which server this file came from + serverName?: string // friendly server name for UI + title: string + artist?: string + album?: string + url: string + thumbnail?: string + duration?: number + tags?: string[] + // Optional MIME/type hints for rendering (video vs audio) + isVideo?: boolean + mimeType?: string + mediaKind?: MediaSection +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..50ddeb6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2021", + "lib": ["DOM", "DOM.Iterable", "ES2021"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6ddd140 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173 + } +})