change installation process

This commit is contained in:
2026-04-18 18:33:10 -07:00
parent 3bd335cfb5
commit e9c72d13df
4 changed files with 167 additions and 133 deletions
+6 -6
View File
@@ -4,8 +4,8 @@ API Media Player is a web-first PWA for browsing Hydrus media and handing playba
## What works today
- Browse the seeded demo library right after `npm install` and `npm run dev`
- Connect to a real Hydrus client from the Settings page or a local `.env` file
- Start the app with `npm start` and connect a Hydrus server from the web settings screen
- Add one or more Hydrus clients from the Settings page or a local `.env` file
- Launch playback in native apps:
- Windows and Linux desktop: `mpv` through `mpv-handler://`
- Android: `mpv-android` through `intent://`
@@ -23,12 +23,12 @@ npm install
3. Start the dev server:
```bash
npm run dev
npm start
```
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)
@@ -41,7 +41,7 @@ VITE_HYDRUS_API_KEY=
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.
@@ -119,7 +119,7 @@ It only activates on `localhost`, loopback, RFC1918 LAN IPs, and `.local` or `.l
## Useful commands
```bash
npm run dev
npm start
npm run build
npm run preview
npm run typecheck
+34 -2
View File
@@ -9,7 +9,7 @@ import DownloadsOverlay, { type DownloadOverlayItem } from './components/Downloa
import DownloadsPage from './pages/DownloadsPage'
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
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 { addDevLog } from './debugLog'
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
@@ -367,9 +367,21 @@ function StartupLibraryCacheSync() {
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() {
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 [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
@@ -452,6 +464,26 @@ function App() {
}
}, [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(() => {
saveUiPreferences({ libraryQuery })
}, [libraryQuery])
+2 -24
View File
@@ -3,8 +3,8 @@ 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 const STORAGE_KEY = 'hydrus_servers_v1'
export const ACTIVE_KEY = 'hydrus_active_id_v1'
export type Server = ServerConfig & {
lastTest?: (ConnectivityResult & { timestamp: number }) | null
@@ -73,28 +73,6 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
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) => {
+125 -101
View File
@@ -50,6 +50,7 @@ type 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 isFirstServerSetup = servers.length === 0
const [editing, setEditing] = useState<Server | null>(null)
const [form, setForm] = useState<Omit<Server, 'id' | 'lastTest'>>(DEFAULT_SERVER_FORM)
const [testing, setTesting] = useState(false)
@@ -264,110 +265,124 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
<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>
{!isFirstServerSetup && (
<IconButton onClick={() => onClose && onClose()} aria-label="back" size="large" sx={{ mr: 1 }}>
<ArrowBackIcon />
</IconButton>
)}
<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 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>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
<Typography variant="body2" color="text.secondary">
Personalize the look and default actions without affecting your cached library.
{!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={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
<Typography variant="body2" color="text.secondary">
Personalize the look and default actions without affecting your cached library.
</Typography>
</Box>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
Save preferences
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
<FormControl size="small">
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
<Select
labelId="settings-app-theme-label"
value={draftAppTheme}
label="Theme"
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
>
{APP_THEME_PRESETS.map((theme) => (
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={draftLibraryDisplayMode}
label="Display"
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-primary-action-label">Track tap</InputLabel>
<Select
labelId="settings-library-primary-action-label"
value={draftLibraryPrimaryAction}
label="Track tap"
onChange={(event) => setDraftLibraryPrimaryAction(event.target.value as LibraryPrimaryAction)}
>
<MenuItem value="details">Open details popup</MenuItem>
<MenuItem value="stream">Stream immediately</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: import.meta.env.DEV ? 2 : 0.5 }}>
{APP_THEME_PRESETS.map((theme) => (
<Chip key={theme.id} label={theme.label} color={draftAppTheme === theme.id ? 'primary' : 'default'} variant={draftAppTheme === theme.id ? 'filled' : 'outlined'} />
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: import.meta.env.DEV ? 0.5 : 0 }}>
{APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description}
</Typography>
{import.meta.env.DEV && (
<>
<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={draftDevOverlayEnabled} onChange={(event) => setDraftDevOverlayEnabled(event.target.checked)} />}
label={draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</>
)}
</Box>
<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 cache</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Storage: ${cacheStorageText}`} size="small" color="primary" variant="outlined" />
<Chip label={`Tracks: ${cacheTrackCount}`} size="small" variant="outlined" />
<Chip label={`Searches: ${cacheSearchCount}`} size="small" variant="outlined" />
<Chip label={`Snapshots kept: ${cacheSnapshotCount}`} size="small" variant="outlined" />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'}
</Typography>
</Box>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
Save preferences
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 240px))' }, gap: 1.5, mb: import.meta.env.DEV ? 2 : 0 }}>
<FormControl size="small">
<InputLabel id="settings-app-theme-label">Theme</InputLabel>
<Select
labelId="settings-app-theme-label"
value={draftAppTheme}
label="Theme"
onChange={(event) => setDraftAppTheme(event.target.value as AppThemeId)}
>
{APP_THEME_PRESETS.map((theme) => (
<MenuItem key={theme.id} value={theme.id}>{theme.label}</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={draftLibraryDisplayMode}
label="Display"
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel id="settings-library-primary-action-label">Track tap</InputLabel>
<Select
labelId="settings-library-primary-action-label"
value={draftLibraryPrimaryAction}
label="Track tap"
onChange={(event) => setDraftLibraryPrimaryAction(event.target.value as LibraryPrimaryAction)}
>
<MenuItem value="details">Open details popup</MenuItem>
<MenuItem value="stream">Stream immediately</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: import.meta.env.DEV ? 2 : 0.5 }}>
{APP_THEME_PRESETS.map((theme) => (
<Chip key={theme.id} label={theme.label} color={draftAppTheme === theme.id ? 'primary' : 'default'} variant={draftAppTheme === theme.id ? 'filled' : 'outlined'} />
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: import.meta.env.DEV ? 0.5 : 0 }}>
{APP_THEME_PRESETS.find((theme) => theme.id === draftAppTheme)?.description}
</Typography>
{import.meta.env.DEV && (
<>
<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={draftDevOverlayEnabled} onChange={(event) => setDraftDevOverlayEnabled(event.target.checked)} />}
label={draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</>
)}
</Box>
<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 cache</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Storage: ${cacheStorageText}`} size="small" color="primary" variant="outlined" />
<Chip label={`Tracks: ${cacheTrackCount}`} size="small" variant="outlined" />
<Chip label={`Searches: ${cacheSearchCount}`} size="small" variant="outlined" />
<Chip label={`Snapshots kept: ${cacheSnapshotCount}`} size="small" variant="outlined" />
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'}
</Typography>
</Box>
</>
)}
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
{!isFirstServerSetup && (
<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 }}>
@@ -423,14 +438,23 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</List>
</Box>
</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 } }}>
<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 }}>
<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="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" 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 />
<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" />