change installation process
This commit is contained in:
@@ -4,8 +4,8 @@ API Media Player is a web-first PWA for browsing Hydrus media and handing playba
|
|||||||
|
|
||||||
## What works today
|
## What works today
|
||||||
|
|
||||||
- Browse the seeded demo library right after `npm install` and `npm run dev`
|
- Start the app with `npm start` and connect a Hydrus server from the web settings screen
|
||||||
- Connect to a real Hydrus client from the Settings page or a local `.env` file
|
- Add one or more Hydrus clients from the Settings page or a local `.env` file
|
||||||
- Launch playback in native apps:
|
- Launch playback in native apps:
|
||||||
- Windows and Linux desktop: `mpv` through `mpv-handler://`
|
- Windows and Linux desktop: `mpv` through `mpv-handler://`
|
||||||
- Android: `mpv-android` through `intent://`
|
- Android: `mpv-android` through `intent://`
|
||||||
@@ -23,12 +23,12 @@ npm install
|
|||||||
3. Start the dev server:
|
3. Start the dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Open `http://localhost:5173`.
|
4. Open `http://localhost:5173`.
|
||||||
|
|
||||||
The app includes sample data, so you can verify the UI immediately. Playback itself is always external, so browsing works before player setup, but actual media launch depends on the platform-specific player flow below.
|
On a fresh install the app opens the Hydrus server settings first. Add your host, port, and API key there, test the connection, then save the server before browsing the library. Playback itself is always external, so actual media launch still depends on the platform-specific player flow below.
|
||||||
|
|
||||||
## Connect Hydrus (optional)
|
## Connect Hydrus (optional)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ VITE_HYDRUS_API_KEY=
|
|||||||
VITE_HYDRUS_SSL=false
|
VITE_HYDRUS_SSL=false
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also add or edit servers from Settings inside the app. The first run seeds a sample server entry so you only need to supply your `Hydrus-Client-API-Access-Key` and test the connection.
|
You can also add or edit servers entirely from Settings inside the app. Nothing is preconfigured on first launch, so you enter the Hydrus connection details yourself.
|
||||||
|
|
||||||
Browsers cannot attach custom Hydrus API headers to direct media URLs. If your Hydrus setup requires header-based authentication for file access, put a trusted reverse proxy in front of it or provide playable URLs another way.
|
Browsers cannot attach custom Hydrus API headers to direct media URLs. If your Hydrus setup requires header-based authentication for file access, put a trusted reverse proxy in front of it or provide playable URLs another way.
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ It only activates on `localhost`, loopback, RFC1918 LAN IPs, and `.local` or `.l
|
|||||||
## Useful commands
|
## Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm start
|
||||||
npm run build
|
npm run build
|
||||||
npm run preview
|
npm run preview
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
|
|||||||
+34
-2
@@ -9,7 +9,7 @@ import DownloadsOverlay, { type DownloadOverlayItem } from './components/Downloa
|
|||||||
import DownloadsPage from './pages/DownloadsPage'
|
import DownloadsPage from './pages/DownloadsPage'
|
||||||
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
||||||
import { ThemeProvider } from '@mui/material/styles'
|
import { ThemeProvider } from '@mui/material/styles'
|
||||||
import { ServersProvider, useServers } from './context/ServersContext'
|
import { ACTIVE_KEY, STORAGE_KEY, ServersProvider, useServers } from './context/ServersContext'
|
||||||
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
|
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
|
||||||
import { addDevLog } from './debugLog'
|
import { addDevLog } from './debugLog'
|
||||||
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
||||||
@@ -367,9 +367,21 @@ function StartupLibraryCacheSync() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialActivePage(): MediaSection | 'settings' | 'downloads' {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return 'settings'
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 ? 'audio' : 'settings'
|
||||||
|
} catch {
|
||||||
|
return 'settings'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
|
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
|
||||||
const [activePage, setActivePage] = useState<MediaSection | 'settings' | 'downloads'>('audio')
|
const [activePage, setActivePage] = useState<MediaSection | 'settings' | 'downloads'>(() => getInitialActivePage())
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
|
||||||
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
|
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
|
||||||
@@ -452,6 +464,26 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [activePage])
|
}, [activePage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
|
const hasServers = Array.isArray(parsed) && parsed.length > 0
|
||||||
|
|
||||||
|
if (!hasServers && activePage !== 'settings') {
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasServers) {
|
||||||
|
localStorage.removeItem(ACTIVE_KEY)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (activePage !== 'settings') {
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activePage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveUiPreferences({ libraryQuery })
|
saveUiPreferences({ libraryQuery })
|
||||||
}, [libraryQuery])
|
}, [libraryQuery])
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient'
|
|||||||
import { HydrusClient, makeId } from '../api/hydrusClient'
|
import { HydrusClient, makeId } from '../api/hydrusClient'
|
||||||
import type { ServerSyncSummary } from '../types'
|
import type { ServerSyncSummary } from '../types'
|
||||||
|
|
||||||
const STORAGE_KEY = 'hydrus_servers_v1'
|
export const STORAGE_KEY = 'hydrus_servers_v1'
|
||||||
const ACTIVE_KEY = 'hydrus_active_id_v1'
|
export const ACTIVE_KEY = 'hydrus_active_id_v1'
|
||||||
|
|
||||||
export type Server = ServerConfig & {
|
export type Server = ServerConfig & {
|
||||||
lastTest?: (ConnectivityResult & { timestamp: number }) | null
|
lastTest?: (ConnectivityResult & { timestamp: number }) | null
|
||||||
@@ -73,28 +73,6 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setActiveServerIdState(servers[0].id)
|
setActiveServerIdState(servers[0].id)
|
||||||
localStorage.setItem(ACTIVE_KEY, 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) => {
|
const setActiveServerId = useCallback((id: string | null) => {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type SettingsPageProps = {
|
|||||||
|
|
||||||
export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, appTheme, onAppThemeChange, libraryDisplayMode, onLibraryDisplayModeChange, libraryPrimaryAction, onLibraryPrimaryActionChange }: SettingsPageProps) {
|
export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayEnabledChange, appTheme, onAppThemeChange, libraryDisplayMode, onLibraryDisplayModeChange, libraryPrimaryAction, onLibraryPrimaryActionChange }: SettingsPageProps) {
|
||||||
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
|
const { servers, addServer, updateServer, removeServer, testServerById, testServerConfig, setActiveServerId, activeServerId } = useServers()
|
||||||
|
const isFirstServerSetup = servers.length === 0
|
||||||
const [editing, setEditing] = useState<Server | null>(null)
|
const [editing, setEditing] = useState<Server | null>(null)
|
||||||
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
|
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
@@ -264,13 +265,24 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
<Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}>
|
<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', justifyContent: 'space-between', gap: 1, mb: 3 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{!isFirstServerSetup && (
|
||||||
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
|
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
|
||||||
<ArrowBackIcon />
|
<ArrowBackIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h6">Hydrus Servers</Typography>
|
)}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">{isFirstServerSetup ? 'Connect to Hydrus' : 'Hydrus Servers'}</Typography>
|
||||||
|
{isFirstServerSetup && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Add your Hydrus Client API host, port, and API key before browsing the library.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{!isFirstServerSetup && (
|
||||||
|
<>
|
||||||
<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 } }}>
|
<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 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5, flexWrap: 'wrap' }}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -366,8 +378,11 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
{cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'}
|
{cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
|
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
|
||||||
|
{!isFirstServerSetup && (
|
||||||
<Grid item xs={12} lg={5}>
|
<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={{ 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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
@@ -423,14 +438,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid item xs={12} lg={7}>
|
<Grid item xs={12} lg={isFirstServerSetup ? 8 : 7} sx={isFirstServerSetup ? { mx: 'auto' } : undefined}>
|
||||||
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 } }}>
|
<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>
|
<Typography variant="subtitle1">{editing ? 'Edit server' : isFirstServerSetup ? 'Add your first server' : 'Add new server'}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
Use the same host, port, and API key you use for the Hydrus Client API.
|
||||||
|
</Typography>
|
||||||
|
{isFirstServerSetup && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
Nothing is preconfigured. Enter your Hydrus server details below, test the connection, then save the server to unlock the library.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<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="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="Host or IP" placeholder="192.168.1.128 or hydrus.local" 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="Port" placeholder="45869" 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 />
|
<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.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" />
|
<FormControlLabel control={<Switch checked={!!form.forceApiKeyInQuery} onChange={(e) => setForm({ ...form, forceApiKeyInQuery: e.target.checked })} />} label="Send API key in query parameter" />
|
||||||
|
|||||||
Reference in New Issue
Block a user