first commit
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
VITE_HYDRUS_HOST=http://localhost
|
||||
VITE_HYDRUS_PORT=45869
|
||||
VITE_HYDRUS_API_KEY=95178b08e6ba3c57991e7b4e162c6efff1ce90c500005c6ebf8524122ed2486e
|
||||
VITE_HYDRUS_SSL=false
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.vscode/
|
||||
.DS_Store
|
||||
/public/icon-*.png
|
||||
168
README.md
Normal file
168
README.md
Normal file
@@ -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.
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>API Media Player</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2465
package-lock.json
generated
Normal file
2465
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
12
public/manifest.json
Normal file
12
public/manifest.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
4
public/no-image.svg
Normal file
4
public/no-image.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="140">
|
||||
<rect fill="#eee" width="100%" height="100%"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="Arial, sans-serif" font-size="20">No Image</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
169
public/sw.js
Normal file
169
public/sw.js
Normal file
@@ -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("<svg xmlns='http://www.w3.org/2000/svg' width='300' height='140'><rect fill='%23eee' width='100%' height='100%'/><text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='20'>No Image</text></svg>", { 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()
|
||||
}
|
||||
})
|
||||
157
public/userscripts/api-media-player-open-in-mpv.user.js
Normal file
157
public/userscripts/api-media-player-open-in-mpv.user.js
Normal file
@@ -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)
|
||||
})()
|
||||
155
scripts/README.md
Normal file
155
scripts/README.md
Normal file
@@ -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
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
155
scripts/README.zh-Hans.md
Normal file
155
scripts/README.zh-Hans.md
Normal file
@@ -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
|
||||
|
||||
如果你是手动安装的,请重新执行一遍手动安装流程。
|
||||
|
||||
## 协议
|
||||
|
||||

|
||||
|
||||
### 协议名
|
||||
|
||||
- `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
|
||||
155
scripts/README.zh-Hant.md
Normal file
155
scripts/README.zh-Hant.md
Normal file
@@ -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
|
||||
|
||||
如果你是手動安裝的,請重新執行一遍手動安裝流程。
|
||||
|
||||
## 協議
|
||||
|
||||

|
||||
|
||||
### 協議名
|
||||
|
||||
- `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
|
||||
18
scripts/config.toml
Normal file
18
scripts/config.toml
Normal file
@@ -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
|
||||
82
scripts/handler-install.bat
Normal file
82
scripts/handler-install.bat
Normal file
@@ -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
|
||||
64
scripts/handler-uninstall.bat
Normal file
64
scripts/handler-uninstall.bat
Normal file
@@ -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
|
||||
229
scripts/install-mpv-handler.ps1
Normal file
229
scripts/install-mpv-handler.ps1
Normal file
@@ -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*"(?<path>[^"]+)"\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.'
|
||||
BIN
scripts/mpv-handler-debug.exe
Normal file
BIN
scripts/mpv-handler-debug.exe
Normal file
Binary file not shown.
BIN
scripts/mpv-handler.exe
Normal file
BIN
scripts/mpv-handler.exe
Normal file
Binary file not shown.
357
src/App.tsx
Normal file
357
src/App.tsx
Normal file
@@ -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<MediaSection | 'settings'>('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<Record<string, MediaInfo>>({})
|
||||
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
|
||||
const playRequestAbortRef = useRef<AbortController | null>(null)
|
||||
const lastBrowsePageRef = useRef<MediaSection>(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<MediaInfo> => {
|
||||
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<MediaInfo> => {
|
||||
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 (
|
||||
<ServersProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
<Header
|
||||
onOpenSettings={openSettings}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
searchQuery={libraryQuery}
|
||||
onSearchQueryChange={setLibraryQuery}
|
||||
searchDisabled={activePage === 'settings'}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<Sidebar
|
||||
mobileOpen={mobileSidebarOpen}
|
||||
desktopOpen={desktopSidebarOpen}
|
||||
onMobileClose={closeSidebar}
|
||||
onNavigate={handleSidebarNavigate}
|
||||
activeId={activePage}
|
||||
/>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', pb: 2, minWidth: 0 }}>
|
||||
{activePage === 'settings'
|
||||
? (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsPage
|
||||
onClose={closeSettings}
|
||||
devOverlayEnabled={devOverlayEnabled}
|
||||
onDevOverlayEnabledChange={handleDevOverlayEnabledChange}
|
||||
libraryDisplayMode={libraryDisplayMode}
|
||||
onLibraryDisplayModeChange={handleLibraryDisplayModeChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
: (
|
||||
<Library
|
||||
mediaSection={activePage}
|
||||
onPlayNow={playNow}
|
||||
query={libraryQuery}
|
||||
onQueryChange={setLibraryQuery}
|
||||
displayModePreference={libraryDisplayMode}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{devOverlayEnabled ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevErrorPanel />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</ServersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
698
src/api/hydrusClient.ts
Normal file
698
src/api/hydrusClient.ts
Normal file
@@ -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<ServerConfig> = {}) {
|
||||
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<string, string> = {}
|
||||
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<string, string | number | undefined> = {}, 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<string>()
|
||||
|
||||
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<number[]> {
|
||||
// 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<ConnectivityResult> {
|
||||
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<string, string> = {}
|
||||
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<string[]> {
|
||||
const data = await this.getFileMetadataPayload(fileId, signal)
|
||||
if (!data) return []
|
||||
return this.extractTagsFromMetadata(data, fileId)
|
||||
}
|
||||
|
||||
async getFileMediaInfo(fileId: number, signal?: AbortSignal): Promise<HydrusMediaInfo> {
|
||||
const data = await this.getFileMetadataPayload(fileId, signal)
|
||||
if (!data) return {}
|
||||
return this.extractMediaInfoFromMetadata(data, fileId)
|
||||
}
|
||||
|
||||
async getFileDetails(fileId: number, signal?: AbortSignal): Promise<HydrusFileDetails> {
|
||||
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<Record<number, string[]>> {
|
||||
const out: Record<number, string[]> = {}
|
||||
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<Record<number, HydrusMediaInfo>> {
|
||||
const out: Record<number, HydrusMediaInfo> = {}
|
||||
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]
|
||||
}
|
||||
|
||||
47
src/appPreferences.ts
Normal file
47
src/appPreferences.ts
Normal file
@@ -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<Record<MediaSection, string>>
|
||||
}
|
||||
|
||||
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<UiPreferences>
|
||||
return {
|
||||
...DEFAULT_UI_PREFERENCES,
|
||||
...parsed,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_UI_PREFERENCES
|
||||
}
|
||||
}
|
||||
|
||||
export function saveUiPreferences(preferences: Partial<UiPreferences>) {
|
||||
try {
|
||||
const nextPreferences = {
|
||||
...loadUiPreferences(),
|
||||
...preferences,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences))
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
128
src/components/DevErrorPanel.tsx
Normal file
128
src/components/DevErrorPanel.tsx
Normal file
@@ -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<DevLogItem[]>(() => 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 (
|
||||
<Box sx={{ position: 'fixed', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 12, zIndex: 9999 }}>
|
||||
<Paper elevation={6} sx={{ minWidth: { xs: 0, sm: 320 }, width: { xs: 'calc(100vw - 24px)', sm: 'auto' }, maxWidth: { xs: 'calc(100vw - 24px)', sm: 640 }, p: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label={`Logs: ${logs.length}`} color={logs.length ? 'info' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
|
||||
<Chip label={`Errors: ${errorCount}`} color={errorCount ? 'error' : 'default'} size="small" clickable onClick={() => setOpen((v) => !v)} />
|
||||
<Typography variant="subtitle2">Dev Log Panel</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button size="small" onClick={() => { clearDevLogs() }} sx={{ mr: 1 }}>Clear</Button>
|
||||
<IconButton size="small" onClick={() => setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{open && (
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto', p: 0 }}>
|
||||
{logs.map((l) => (
|
||||
<ListItem key={l.id} divider alignItems="flex-start">
|
||||
<ListItemText primary={`${l.kind} — ${new Date(l.time).toLocaleTimeString()}`} secondary={<>
|
||||
<Typography component="div" variant="body2">{l.message}</Typography>
|
||||
{l.category && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.category}</Typography>}
|
||||
{l.source && <Typography component="div" variant="caption" sx={{ color: 'text.secondary' }}>{l.source}</Typography>}
|
||||
{l.stack && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.stack}</Typography>}
|
||||
{l.details && <Typography component="pre" variant="caption" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>{l.details}</Typography>}
|
||||
</>} />
|
||||
<IconButton edge="end" size="small" aria-label="copy log entry" onClick={() => { void copyLog(l) }} sx={{ ml: 1, alignSelf: 'flex-start' }}>
|
||||
<ContentCopyIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
104
src/components/Header.tsx
Normal file
104
src/components/Header.tsx
Normal file
@@ -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<HTMLElement | null>(null)
|
||||
|
||||
const active = servers.find((s) => s.id === activeServerId)
|
||||
const activeServerLabel = active ? active.name || active.host : 'No server configured'
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)
|
||||
const handleClose = () => setAnchor(null)
|
||||
|
||||
return (
|
||||
<AppBar position="static" color="transparent" elevation={0} sx={{ mb: 0, borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<Toolbar variant="dense" sx={{ minHeight: { xs: 'auto', sm: 48 }, py: { xs: 1, sm: 0.25 }, gap: 1, flexWrap: { xs: 'wrap', sm: 'nowrap' } }}>
|
||||
<IconButton onClick={() => onToggleSidebar && onToggleSidebar()} aria-label="menu" size="medium" sx={{ flexShrink: 0 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<TextField
|
||||
value={searchQuery}
|
||||
onChange={(event) => 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 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconButton onClick={() => onOpenSettings && onOpenSettings()} aria-label="settings" size="medium" sx={{ width: 40, height: 40, flexShrink: 0, order: { xs: 2, sm: 0 } }}>
|
||||
<SettingsIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: { xs: '100%', sm: 'auto' }, minWidth: 0, order: { xs: 3, sm: 0 } }}>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={handleOpen}
|
||||
startIcon={<StorageIcon sx={{ fontSize: 18 }} />}
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
fontSize: { xs: 13, sm: 13 },
|
||||
justifyContent: 'flex-start',
|
||||
minWidth: 0,
|
||||
flex: { xs: 1, sm: '0 1 auto' },
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{activeServerLabel}
|
||||
</Button>
|
||||
{active?.lastTest && active.lastTest.ok === false && (
|
||||
<Chip label={active.lastTest.message} color="error" size="small" sx={{ maxWidth: { xs: 132, sm: 200 } }} />
|
||||
)}
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={handleClose}>
|
||||
{servers.length === 0 ? (
|
||||
<MenuItem disabled>No servers configured</MenuItem>
|
||||
) : (
|
||||
servers.map((s) => (
|
||||
<MenuItem
|
||||
key={s.id}
|
||||
selected={s.id === activeServerId}
|
||||
onClick={() => {
|
||||
setActiveServerId(s.id)
|
||||
handleClose()
|
||||
}}
|
||||
>
|
||||
{s.name || s.host}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
handleClose()
|
||||
onOpenSettings && onOpenSettings()
|
||||
}}
|
||||
>
|
||||
Manage servers...
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
118
src/components/Sidebar.tsx
Normal file
118
src/components/Sidebar.tsx
Normal file
@@ -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: <LibraryMusicIcon /> },
|
||||
{ id: 'audio', label: 'Audio', icon: <AudiotrackIcon /> },
|
||||
{ id: 'video', label: 'Video', icon: <MovieIcon /> },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon /> },
|
||||
{ id: 'application', label: 'Applications', icon: <AppsIcon /> },
|
||||
]
|
||||
|
||||
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 = (
|
||||
<Box sx={{ width: drawerWidth, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 36, height: 36, borderRadius: 1, background: 'linear-gradient(135deg,#1db954,#1ed760)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700 }}>
|
||||
H
|
||||
</Box>
|
||||
<Typography variant="h6" component="div" sx={{ fontSize: 16, fontWeight: 600 }}>
|
||||
Hydrus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ opacity: 0.08 }} />
|
||||
|
||||
<List sx={{ p: 1, flex: 1 }}>
|
||||
{ITEMS.map((it) => (
|
||||
<ListItemButton key={it.id} selected={activeId === it.id} onClick={() => handleNavigate(it.id)} sx={{ borderRadius: 1, mb: 0.5 }}>
|
||||
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>{it.icon}</ListItemIcon>
|
||||
<ListItemText primary={it.label} primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ opacity: 0.08 }} />
|
||||
|
||||
<Box sx={{ p: 1 }}>
|
||||
<ListItemButton selected={activeId === 'settings'} onClick={() => handleNavigate('settings')} sx={{ borderRadius: 1 }}>
|
||||
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" primaryTypographyProps={{ fontSize: 14 }} />
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: desktopOpen ? drawerWidth : 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
duration: theme.transitions.duration.standard,
|
||||
easing: theme.transitions.easing.sharp,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
height: '100%',
|
||||
borderRight: '1px solid rgba(255,255,255,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
backgroundImage: 'none',
|
||||
transform: desktopOpen ? 'translateX(0)' : `translateX(-${drawerWidth}px)`,
|
||||
transition: (theme) => theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.standard,
|
||||
easing: theme.transitions.easing.sharp,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Temporary drawer for mobile */}
|
||||
<Drawer anchor="left" open={Boolean(mobileOpen)} onClose={onMobileClose} ModalProps={{ keepMounted: true }} PaperProps={{ sx: { width: drawerWidth } }} sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
{content}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
144
src/context/ServersContext.tsx
Normal file
144
src/context/ServersContext.tsx
Normal file
@@ -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, 'id' | 'lastTest'>) => Server
|
||||
updateServer: (id: string, patch: Partial<Server>) => void
|
||||
removeServer: (id: string) => void
|
||||
testServerById: (id: string) => Promise<ConnectivityResult>
|
||||
testServerConfig: (cfg: Omit<Server, 'id' | 'lastTest'>) => Promise<ConnectivityResult>
|
||||
}
|
||||
|
||||
const ServersContext = createContext<ServersContextType | null>(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<Server[]>(() => loadServers())
|
||||
const [activeServerId, setActiveServerIdState] = useState<string | null>(() => {
|
||||
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<Server, 'id' | 'lastTest'>) => {
|
||||
const id = makeId()
|
||||
const srv: Server = { id, ...s, lastTest: null }
|
||||
setServers((prev) => [...prev, srv])
|
||||
return srv
|
||||
}, [])
|
||||
|
||||
const updateServer = useCallback((id: string, patch: Partial<Server>) => {
|
||||
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<Server, 'id' | 'lastTest'>) => {
|
||||
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 (
|
||||
<ServersContext.Provider value={value}>
|
||||
{children}
|
||||
</ServersContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServers() {
|
||||
const ctx = useContext(ServersContext)
|
||||
if (!ctx) throw new Error('useServers must be used within ServersProvider')
|
||||
return ctx
|
||||
}
|
||||
62
src/debugLog.ts
Normal file
62
src/debugLog.ts
Normal file
@@ -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<DevLogItem, 'id' | 'time' | 'details'> & { 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)
|
||||
}
|
||||
}
|
||||
99
src/libraryCache.ts
Normal file
99
src/libraryCache.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Track } from './types'
|
||||
|
||||
type CacheServerDescriptor = {
|
||||
id: string
|
||||
host: string
|
||||
port?: string | number
|
||||
ssl?: boolean
|
||||
}
|
||||
|
||||
type PersistedTrack = Omit<Track, 'id'>
|
||||
|
||||
type LibraryCacheRecord = {
|
||||
cacheKey: string
|
||||
updatedAt: number
|
||||
tracks: PersistedTrack[]
|
||||
searchCache: Record<string, number[]>
|
||||
}
|
||||
|
||||
export type LibraryCacheSnapshot = {
|
||||
tracks: PersistedTrack[]
|
||||
searchCache: Record<string, number[]>
|
||||
}
|
||||
|
||||
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<IDBDatabase>((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<T>(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest<T>) {
|
||||
return new Promise<T>((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<LibraryCacheSnapshot | null> {
|
||||
if (!cacheKey || typeof indexedDB === 'undefined') return null
|
||||
|
||||
const record = await withStore<LibraryCacheRecord | undefined>('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<string, number[]>) {
|
||||
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))
|
||||
}
|
||||
18
src/main.tsx
Normal file
18
src/main.tsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
registerServiceWorker()
|
||||
} else {
|
||||
// In development, unregister service workers to avoid caching/fetch oddities while iterating
|
||||
unregisterServiceWorker()
|
||||
}
|
||||
27
src/mediaCache.ts
Normal file
27
src/mediaCache.ts
Normal file
@@ -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
|
||||
}
|
||||
1558
src/pages/Library.tsx
Normal file
1558
src/pages/Library.tsx
Normal file
File diff suppressed because it is too large
Load Diff
381
src/pages/SettingsPage.tsx
Normal file
381
src/pages/SettingsPage.tsx
Normal file
@@ -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<Server | null>(null)
|
||||
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [syncingServerId, setSyncingServerId] = useState<string | null>(null)
|
||||
const [lastTest, setLastTest] = useState<string | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [detailsText, setDetailsText] = useState<string | null>(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<string, Track> = {}
|
||||
|
||||
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 (
|
||||
<Box sx={{ p: { xs: 1, sm: 2, lg: 3 }, minHeight: '100%', bgcolor: 'background.default' }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6">Hydrus Servers</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{import.meta.env.DEV && (
|
||||
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Choose the default Library layout so browsing controls can stay compact.
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: 2 }}>
|
||||
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
|
||||
<Select
|
||||
labelId="settings-library-display-mode-label"
|
||||
value={libraryDisplayMode}
|
||||
label="Display"
|
||||
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
|
||||
>
|
||||
<MenuItem value="grid">Grid</MenuItem>
|
||||
<MenuItem value="table">Table</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Developer tools</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Control development-only UI that can get in the way on smaller screens.
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={devOverlayEnabled} onChange={(event) => onDevOverlayEnabledChange(event.target.checked)} />}
|
||||
label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
|
||||
sx={{ alignItems: 'flex-start', m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!import.meta.env.DEV && (
|
||||
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Choose the default Library layout so browsing controls can stay compact.
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240 }}>
|
||||
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
|
||||
<Select
|
||||
labelId="settings-library-display-mode-label"
|
||||
value={libraryDisplayMode}
|
||||
label="Display"
|
||||
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
|
||||
>
|
||||
<MenuItem value="grid">Grid</MenuItem>
|
||||
<MenuItem value="table">Table</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="subtitle1">Configured servers</Typography>
|
||||
<Button startIcon={<AddIcon />} onClick={startAdd} size="large" className="touch-target">
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<List>
|
||||
{servers.length === 0 && <Typography color="text.secondary">No servers configured yet</Typography>}
|
||||
{servers.map((s) => (
|
||||
<ListItem key={s.id} sx={{ flexWrap: 'wrap', alignItems: 'flex-start', px: 0, py: 1.25, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<ListItemText primary={s.name || s.host} secondary={s.host} sx={{ mr: 2 }} />
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: { xs: 1, md: 0 }, flexWrap: 'wrap', width: { xs: '100%', md: 'auto' } }}>
|
||||
<Button size="large" onClick={() => setActiveServerId(s.id)} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
{s.id === activeServerId ? 'Active' : 'Set active'}
|
||||
</Button>
|
||||
<Button variant="outlined" size="large" onClick={() => handleTestExisting(s)} startIcon={<PlayArrowIcon />} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="outlined" size="large" onClick={() => handleSyncServer(s)} startIcon={<CloudDownloadIcon />} disabled={syncingServerId === s.id} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
{syncingServerId === s.id ? 'Syncing...' : 'Sync cache'}
|
||||
</Button>
|
||||
<IconButton size="large" aria-label="edit" onClick={() => startEdit(s)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton size="large" aria-label="delete" onClick={() => handleDelete(s)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', width: '100%', mt: 1 }}>
|
||||
{s.lastTest && s.lastTest.message && <Chip label={s.lastTest.message} size="small" />}
|
||||
{s.syncSummary?.message && <Chip label={s.syncSummary.message} size="small" color="primary" variant="outlined" />}
|
||||
{s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => (
|
||||
<Chip key={`${s.id}-${section}`} label={`${section}: ${count}`} size="small" variant="outlined" />
|
||||
))}
|
||||
{s.forceApiKeyInQuery && <Chip label="API key in query" size="small" />}
|
||||
</Box>
|
||||
{s.syncSummary?.updatedAt && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ width: '100%', mt: 1 }}>
|
||||
Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography variant="subtitle1">{editing ? 'Edit server' : 'Add new server'}</Typography>
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField label="Name (optional)" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} fullWidth />
|
||||
<TextField label="Host (IP or host, e.g. 192.168.1.128)" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} fullWidth />
|
||||
<TextField label="Port (optional)" value={form.port as any ?? ''} onChange={(e) => setForm({ ...form, port: e.target.value })} fullWidth />
|
||||
<TextField label="API Key (optional)" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} fullWidth />
|
||||
<FormControlLabel control={<Switch checked={!!form.ssl} onChange={(e) => setForm({ ...form, ssl: e.target.checked })} />} label="Use HTTPS (SSL)" />
|
||||
<FormControlLabel control={<Switch checked={!!form.forceApiKeyInQuery} onChange={(e) => setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1, flexWrap: 'wrap' }}>
|
||||
<Button variant="contained" onClick={handleSave} disabled={!form.host} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleTestForm} disabled={!form.host || testing} startIcon={<PlayArrowIcon />} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
{testing ? 'Testing...' : 'Test connection'}
|
||||
</Button>
|
||||
<Button onClick={() => { setEditing(null); setForm(DEFAULT_SERVER_FORM); setLastTest(null) }} size="large" className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{lastTest && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="caption">Last test: {lastTest}</Typography>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Button size="small" onClick={() => {
|
||||
const obj = editing?.lastTest ?? servers.find((s) => s.id === editing?.id)?.lastTest ?? null
|
||||
setDetailsText(obj ? JSON.stringify(obj, null, 2) : lastTest)
|
||||
setDetailsOpen(true)
|
||||
}}>Show details</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Dialog open={detailsOpen} onClose={() => setDetailsOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>Connection details</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>{detailsText}</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailsOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
5
src/sampleData.ts
Normal file
5
src/sampleData.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Track } from './types'
|
||||
|
||||
// No bundled sample tracks by default
|
||||
export const SAMPLE_TRACKS: Track[] = []
|
||||
|
||||
16
src/serviceWorker.ts
Normal file
16
src/serviceWorker.ts
Normal file
@@ -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()))
|
||||
}
|
||||
}
|
||||
55
src/styles.css
Normal file
55
src/styles.css
Normal file
@@ -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; }
|
||||
}
|
||||
|
||||
26
src/types.ts
Normal file
26
src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type MediaSection = 'all' | 'audio' | 'video' | 'image' | 'application'
|
||||
|
||||
export type ServerSyncSummary = {
|
||||
updatedAt: number
|
||||
total: number
|
||||
counts: Partial<Record<MediaSection, number>>
|
||||
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
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user