This content has been generated by GLM 5.1 AI model
So, picture this: you've got a Raspberry Pi hooked up to some speakers in your living room. It's running mpv because you're not an animal — you use a proper media player. But every time you want to skip a track, change the volume, or browse your library, you have to SSH into the thing and type commands like some kind of... command line... person.
Yeah, me too. And after the 47th time typing mpv --no-video /mnt/music/SomeAlbum/song.flac while my pasta was burning, I decided enough was enough.
mpv-web-control is a web-based remote control for mpv. You open a browser on your phone (or any device on your LAN), and you get a full music player interface — browse your library, queue files and folders, play/pause/skip/seek/volume, save and load playlists. All pointing at mpv running on your Pi.
No native app. No cloud service. No monthly subscription to control your own music on your own speakers. Just a web page on your local network.
What it does
The feature list is what you'd expect from a music player remote:
- Browse your music library by folder (no tagging system, just files)
- Queue individual files or entire folders recursively
- Playback controls — play, pause, next, previous, stop, seek, volume
- Queue management — see what's playing, jump to any track, clear the queue
- Playlists — save your current queue as a JSON file, load it later, append to it, delete it
No database. Playlists are just JSON files on disk. The library is whatever is under your MUSIC_ROOT directory. Simple, stupid, and it works.
The stack
This is a pnpm monorepo with three apps and a shared contract package:
- Server — Hono on Node.js, talks to mpv via JSON IPC over a Unix socket
- Client — React SPA with TanStack Router and TanStack Query, shadcn/ui components
- Contract — Shared TypeScript types between server and client
- CLI —
mpv-web-controlbinary with install/uninstall/start/package commands
The server spawns mpv as a child process with --no-video --idle=yes and communicates through a Unix socket using mpv's JSON IPC protocol. Every command goes through proper validation with Zod schemas, and path traversal protection ensures nobody can escape the MUSIC_ROOT directory (this runs on your LAN, but still — I'm not that reckless).
typescriptfunction resolveLibraryPath(inputPath: string): string { if (isAbsolute(inputPath)) { throw new Error('Absolute paths are not allowed') } const absolutePath = resolve(config.musicRoot, inputPath) const relativePath = relative(config.musicRoot, absolutePath) if (relativePath.startsWith('..') || isAbsolute(relativePath)) { throw new Error('Path escapes MUSIC_ROOT') } return absolutePath }
The client polls the server every second for player status. TanStack Query handles caching and invalidation — when you queue a file, the status query gets invalidated so the UI updates immediately. Optimistic enough to feel snappy, not so optimistic that it lies to you.
The mpv communication
This was the interesting part. The MpvService class manages the lifecycle:
- Spawns mpv as a child process
- Waits for the IPC socket to appear (polls for 5 seconds)
- Connects to the socket
- Sends commands as JSON with request IDs
- Matches responses back to pending requests
Each command gets a unique request ID and a 5-second timeout. The socket data handler buffers incoming data, splits on newlines, parses JSON, and routes responses to their waiting promises. Classic request-response over a text stream, nothing fancy but it gets the job done.
typescriptasync status(): Promise<PlayerStatus> { await this.ensureStarted() const [paused, position, duration, volume, playlist, playlistPos] = await Promise.all([ this.safeProperty<boolean>('pause'), this.safeProperty<number>('time-pos'), this.safeProperty<number>('duration'), this.safeProperty<number>('volume'), this.safeProperty<MpvPlaylistItem[]>('playlist'), this.safeProperty<number>('playlist-pos'), ]) // ... builds the status object }
If mpv crashes or the socket dies, the service cleans up and restarts on the next request. Because hardware happens, especially on a Pi ^^
Installation (the easy way)
bashnpm install -g mpv-web-control sudo mpv-web-control install --user $USER --music-root /mnt/music --port 3000
That's it. The install command writes the config to /etc/mpv-web-control/env, creates a systemd unit, enables it, and starts the service. Your music remote is now available at http://your-pi:3000 from any device on your network.
bashsudo systemctl status mpv-web-control sudo journalctl -u mpv-web-control -f
For uninstalling, sudo mpv-web-control uninstall stops the service, disables it, and asks what to do with config and data. It does not delete your Linux user (you're welcome).
Environment variables
Everything is configurable:
| Variable | Default | What it does |
|----------|---------|-------------|
| HOST | 0.0.0.0 | Bind address for LAN access |
| PORT | 3000 | Server port |
| MUSIC_ROOT | current directory | Where your music lives |
| PLAYLISTS_DIR | .mpv-web-control/playlists | Where playlists get saved |
| MPV_SOCKET_PATH | /tmp/mpv-web-control.sock | mpv IPC socket path |
| MPV_BIN | mpv | mpv binary path |
| MAX_FOLDER_ITEMS | 5000 | Safety cap for recursive folder queueing |
The MAX_FOLDER_ITEMS cap exists because I accidentally tried to queue /mnt/music once and... let's just say mpv was not happy about receiving 47,000 files. Safety first, kids.
The release script
This project even has a release script because apparently I enjoy writing bash almost as much as I enjoy writing TypeScript:
bashbash scripts/release.sh 1.0.0 --dry-run # preview what would happen bash scripts/release.sh 1.0.0 # actually do it
It validates the version, runs typecheck and build, bumps all package.json files, commits, tags, pushes, creates a GitHub Release with a tarball, and publishes to npm. All in one shot. Flags like --no-gh, --no-npm, and --dry-run let you control what runs.
Security notes
This is designed for trusted LAN use. Path traversal protection is in place (absolute paths and ../ escape attempts get rejected), but there's no authentication. Do NOT expose this directly to the internet. Your music is between you and your speakers, where it belongs.
Why I built this
Because I was tired of SSH-ing into my Pi to change tracks. That's it. That's the whole reason. Sometimes the best motivation is sheer laziness.
But also? It's a nice example of a full-stack TypeScript project with proper separation of concerns. The shared contract package means the client and server always agree on types. Hono makes the backend lightweight and fast. The React client with TanStack Query handles real-time updates cleanly. And the whole thing installs as a systemd service with one command.
Sometimes the best projects come from the dumbest problems ^^
{{% goodbye %}}