Progressive web app to manage all your odio instances from one place.
Part of the odio project — full documentation.
Progressive Web App for discovering and controlling odio-api instances on your local network.
Manage multiple odio-api endpoints from a single interface: add instances manually or scan your network for them, see what's playing on each one and control playback, monitor their status, and connect to their embedded web UI via iframe with one-tap switching.
- Instance management: add, edit, delete odio-api instances (IP/hostname + port), persisted in localStorage; instances playing something are listed first, then paused ones, unreachable ones last
- Network scan: find the odio-api instances on your LAN from an IP, a network with its mask, or nothing at all: the app then scans the network of the instances already online, or looks for yours among common router and ISP defaults
- Now playing: each instance card shows the active MPRIS media player (audio or video): cover art (click to zoom), title, artist, album and progress, with previous / play-pause / next driven by the player's capabilities; one tab per player when several are active
- Power off: a power button in the card header, behind a confirmation, when odio-api's power backend allows it
- Deep linking — open an instance directly via
#/i/<host>/<port>?label=<name>(port and label optional). First-time visits stay in-memory only and prompt to save on exit, so QR codes and shared links don't silently pollute the user's list - Real-time status — live updates via SSE (
/events), up to 6 concurrent connections; additional instances fall back to HTTP polling every 30 s - Smart reconnect — exponential backoff on connection loss (1 s → 30 s cap); gives up after 90 s of consecutive failures
- Power event handling — reboot shows a waiting screen and auto-reloads when the server comes back; poweroff offers to wait or go back to the list immediately; servers without SSE support are detected automatically and use polling only
- Embedded UI — loads each instance's
/uiin a full-screen iframe - Quick switch — compact top bar with dropdown to jump between online instances
- PWA — installable, offline-capable app shell, service worker caching
- Dark theme — responsive layout (single column on mobile, grid on desktop)
- In-app diagnostics: Firefox / Safari desktop on HTTPS get a persistent banner and disabled "+ Add Instance" and "Scan network" buttons; instance cards distinguish Server unreachable, Server reachable: missing CORS headers, and Browser blocked (mixed content) so the user knows what to fix
git clone https://github.com/b0bbywan/odio-pwa.git
cd odio-pwa
npm installStart the dev server:
npm run devOpens at http://localhost:5173 by default.
Run type checking:
npm run checksrc/
├── lib/
│ ├── types.ts # OdioServerInfo, OdioInstance, MprisPlayer, PowerCapabilities
│ ├── api.ts # odio-api client: /server, /players, /power, cover URLs
│ ├── sse.ts # connectSSE(): thin EventSource wrapper
│ ├── connection.ts # createConnection(): backoff, give-up, SSE/polling, players, power
│ ├── players.ts # Pure player helpers: upsert, positions, active players, time format
│ ├── placement.svelte.ts # Settled list order: unreachable instances last, no jumping
│ ├── scan.ts # Network scan: target parsing, candidate networks, link and host probes
│ ├── scanner.svelte.ts # One scan from target to results: steps, progress, stop
│ ├── browser.ts # Mixed-content detection per browser
│ ├── github.ts # Latest release check for the update indicator
│ └── state.svelte.ts # Reactive state (Svelte 5 runes), instancePath() helper
├── components/
│ ├── InstanceList.svelte # Discovery screen with card grid
│ ├── InstanceCard.svelte # Status indicator, server info, now playing, actions
│ ├── NowPlaying.svelte # Active player(s): tabs, track text
│ ├── NowPlayingCover.svelte # Cover art with fullscreen zoom
│ ├── NowPlayingProgress.svelte # Progress bar, ticking while playing
│ ├── NowPlayingControls.svelte # Previous / play-pause / next
│ ├── PowerOffButton.svelte # Power-off icon with its inline confirmation
│ ├── AddInstanceForm.svelte # Add/edit form (host, port, label)
│ ├── Modal.svelte # Native modal <dialog>: focus kept inside, Escape, backdrop click
│ ├── ScanModal.svelte # Network scan dialog: summary and actions around a Scanner
│ ├── ScanForm.svelte # Scan target and port
│ ├── ScanProgress.svelte # Scan steps and progress bar
│ ├── ScanResults.svelte # Instances found, with Add or an already-added check
│ ├── InstanceTopBar.svelte # Navigation bar with instance switcher + Save button
│ ├── InstanceView.svelte # Iframe embed, power event orchestration, save prompt
│ ├── ConnectionStatusScreen.svelte # Full-screen connecting / unreachable / blocked state
│ ├── PowerScreen.svelte # Full-screen reboot/poweroff state display
│ ├── SavePrompt.svelte # Save / Don't save / Cancel dialog for deep-linked instances
│ └── ReloadPrompt.svelte # PWA update toast
├── data/schemas/site.ts # SEO strings and JSON-LD, injected at build time
├── App.svelte # Hash routing (svelte-spa-router): / and /i/:host/:port?
├── app.css # Global styles, dark theme
└── main.ts # App bootstrap
- Svelte 5 with runes (
$state,$derived,$effect) - svelte-spa-router for hash-based deep linking
- Vite
- vite-plugin-pwa with Workbox
- Vitest + @testing-library/svelte for unit and component tests
Run tests:
npm run testnpm run build
npm run previewThe production build outputs to dist/. The preview server serves it at http://localhost:4173.
- Open the app in your browser
- Tap + Add Instance and enter the IP/hostname and port (default
8018) of an odio-api instance, or tap Scan network to find the instances on your LAN (see Network scan) - The app probes
/serverto check if the instance is online - Online instances show what's playing, with playback controls, and a power button when odio-api allows powering off
- Tap Connect on an online instance to load its UI in an iframe
- Use the switch dropdown in the top bar to jump between instances
You can also open an instance straight from a URL:
https://pwa.odio.love/#/i/<host>[/<port>][?label=<name>]
hostis required,portdefaults to8018,labelis optional- Examples:
#/i/192.168.1.10,#/i/odio.local/9000?label=Living%20Room - The instance is held in-memory only; a Save button appears in the top bar to persist it, and leaving without saving (in-app or browser back) prompts to Save / Don't save / Cancel
odio-pwa uses Server-Sent Events (/events) for real-time status updates when the odio-api version supports it. If the endpoint is not available (404 or connection error before the stream opens), the app automatically falls back to HTTP polling every 30 s — no configuration required.
The status probe (fetch /server), the SSE stream (fetch /events), the player and power endpoints (/players, /power) are cross-origin requests. Player and power actions are bodyless POSTs, CORS "simple requests" that need no preflight. Your odio-api instances must allow the PWA's origin:
Access-Control-Allow-Origin: https://your-pwa.example
In odio-api, add that origin to api.cors.origins (or "*" to allow any). https://pwa.odio.love is allowed by default.
The iframe loading /ui does not require CORS headers.
Scan network probes GET /server on every host of a target, 48 at a time, and lists the odio-api instances that answer. The target is a single IP or hostname, or a network with its mask (192.168.1.0/24 or 192.168.1.0/255.255.255.0), up to a /22.
A browser can't read its own IP or routing table, so with no target the app works out where to look:
- If an instance of the list is online and was added by IP, its /24 is scanned straight away.
- Otherwise around forty networks are checked: the /24 of the instances known but offline, then the defaults of common routers, ISP boxes and hotspots around the world.
- If none of them is connected, the rest of
192.168.0.0/16is swept.
A network counts as connected when a request to one of its hosts fails fast or gets an answer: on a network the device is directly attached to, the OS gives up on an unassigned IP once ARP fails (about 3 s on Linux), while packets to a network that isn't routed are dropped upstream and the request hangs. Each network is judged on two or three hosts, each given 5 s to fail.
Limits:
- Only directly connected networks are found; one behind another router has to be entered by hand.
10.0.0.0/8and172.16.0.0/12are too large to sweep: only a few known defaults are checked there.- The scan reads the answer of
/server, so it only lists instances that allow the PWA's origin (see CORS). Hosts that answer on the port without allowing it are counted, next to the origin to add to theirapi.cors.origins; the browser can't tell whether they are odio-api.
The service worker caches only the app shell (HTML, CSS, JS, icons). It does not cache cross-origin requests to odio-api instances. The app is installable on supported browsers and works offline (showing the cached instance list — instances will appear as offline until the network is available).
A Docker image is published to GHCR for each v* tag, alongside a zip of the static build attached to the GitHub Release.
docker run -d -p 8080:80 --restart unless-stopped \
--name odio-pwa ghcr.io/b0bbywan/odio-pwa:latestThe image is multi-arch (linux/amd64, linux/arm64) and ships an nginx configured for SPA routing and PWA cache headers.
Download odio-pwa-<version>.zip from the Releases page and serve the extracted files with any static web server. Make sure to:
- rewrite unknown routes to
/index.html(SPA fallback) - serve
/index.html,/sw.js,/registerSW.jswithCache-Control: no-cacheso PWA updates propagate
The PWA makes HTTP calls to your odio-api instances on the LAN. When the PWA itself is served over HTTPS (e.g. behind a public reverse proxy), behavior varies by browser:
- Chrome 142+ shows a Local Network Access permission prompt on the first HTTP LAN call; granting it bypasses the mixed-content block for private IPs,
.localdomains, and loopback. - Safari (iOS/macOS) blocks mixed-content fetches strictly — no LAN exemption in WebKit.
- Firefox exempts
localhost/.localhost, but private IPs are still blocked (MDN: Mixed content).
Serving the PWA over plain HTTP on the LAN avoids all of this.
When the PWA detects Firefox or Safari desktop on an HTTPS context, it shows a persistent banner pointing back at this guide and disables the "+ Add Instance" and "Scan network" buttons (no point in adding nodes that cannot be reached).
When a probe fails for an existing instance, the card distinguishes three cases by re-probing with mode: 'no-cors':
- Server unreachable (red): the server is genuinely down, or the request was blocked at the network layer.
- Server reachable: missing CORS headers (amber): the request round-tripped but the response lacks
Access-Control-Allow-Origin. Add the PWA's origin to the node'sapi.cors.origins. - Browser blocked (mixed content) (amber): both the regular and
no-corsprobes failed on an HTTPS PWA, so a browser-level block is the most likely cause.
The app checks GitHub for a newer stable release and shows an arrow next to the version when one is available. By default prereleases are ignored. To also surface prereleases (useful when testing an RC image), run this in the browser devtools:
localStorage.setItem('odio-include-prereleases', 'true'); location.reload();To revert: localStorage.removeItem('odio-include-prereleases'); location.reload().