A local media browser and playback controller built on a React + Hono full-stack Vite setup. The app scans a configured directory on the host machine, lists video files grouped by folder, and controls a running mpv process via its IPC socket — all from a browser UI that works on both desktop and mobile.
| Layer | Technology |
|---|---|
| Frontend | React 18, TypeScript, Tailwind CSS v4 |
| Components | shadcn/ui (Radix UI primitives) |
| State / data fetching | TanStack Query v5, Zustand |
| API server | Hono (mounted inside Vite dev server) |
| Runtime / package manager | Bun |
| Media player | mpv (controlled via Unix IPC socket) |
A .env file is required in the project root. It is loaded by vite.config.ts using loadEnv and merged into process.env so the Hono API can read it.
MEDIA_PATH=/absolute/path/to/your/media/root
The .env file is gitignored. See .env.example for the required variables.
bun run dev # Start dev server (frontend + API on the same origin)
bun run build # Production build
bun run lint # ESLint
bun run preview # Preview the production buildThe dev server runs on http://localhost:5173. API routes are available at http://localhost:5173/api/*.
media-center/
├── api/ # Hono API server (Node.js, runs inside Vite)
│ ├── app.ts # All API routes and mpv IPC logic
│ ├── dev.ts # Vite dev-server entry point (just re-exports app)
│ └── tsconfig.json # TypeScript config scoped to the API
│
├── src/ # React frontend
│ ├── main.tsx # App entry — mounts QueryClientProvider + ThemeProvider
│ ├── App.tsx # Root component — media browser UI, search, play dialog
│ ├── App.css # Minimal root height rule
│ ├── index.css # Tailwind import, CSS variable theme tokens (light + dark)
│ │
│ ├── components/
│ │ ├── providers/
│ │ │ └── theme-provider.tsx # Applies "light"/"dark" class to <html>, reads useThemeStore
│ │ └── ui/
│ │ ├── player-bar.tsx # Persistent bottom player bar (see below)
│ │ ├── button.tsx # shadcn Button
│ │ ├── dialog.tsx # shadcn Dialog (used for play confirmation)
│ │ ├── dropdown-menu.tsx # shadcn DropdownMenu (used for subtitle selector)
│ │ └── context-menu.tsx # shadcn ContextMenu
│ │
│ └── lib/
│ ├── utils.ts # cn() helper (clsx + tailwind-merge)
│ └── hooks/
│ ├── api/
│ │ ├── use-directory-content.ts # useMediaFiles, useDirectoryContent hooks + types
│ │ └── use-player.ts # All player hooks + types (see below)
│ └── store/
│ └── use-theme-store.ts # Zustand store — persists "light" | "dark" theme
│
├── packages/ # Shared code between frontend and API (aliased as @shared)
├── public/ # Static assets
├── vite.config.ts # Vite config — loads .env into process.env, mounts Hono
├── index.html # HTML entry point
├── .env # Local env vars (gitignored)
├── .env.example # Template showing required variables
└── package.json
All routes are prefixed with /api and defined in api/app.ts.
| Method | Route | Description |
|---|---|---|
GET |
/api/browse?path=<dir> |
Lists all entries in a directory with type and size |
GET |
/api/media?path=<dir> |
Lists video files grouped by subdirectory. Defaults to MEDIA_PATH. Filters by VIDEO_EXTENSIONS |
All player routes communicate with mpv via a Unix IPC socket at /tmp/mpv-media-center.sock. The status route checks for the socket file directly, so it reconnects to a running mpv even after the API server restarts.
| Method | Route | Body | Description |
|---|---|---|---|
GET |
/api/player/status |
— | Returns full player state. { playing, paused, title, position, duration, volume, fullscreen, subtitles, subtitleOffset } |
POST |
/api/player/play |
{ path: string } |
Kills any running mpv, spawns a new instance with the IPC socket flag |
POST |
/api/player/pause |
— | Sets pause = true via IPC |
POST |
/api/player/resume |
— | Sets pause = false via IPC |
POST |
/api/player/stop |
— | Kills mpv process or sends quit via IPC if process is unowned |
POST |
/api/player/seek |
{ position: number } |
Seeks to absolute position in seconds |
POST |
/api/player/skip |
{ seconds: number } |
Seeks relative to current position (use negative to go back) |
POST |
/api/player/volume |
{ volume: number } |
Sets volume (0–130, matching mpv's range) |
POST |
/api/player/fullscreen |
{ fullscreen: boolean } |
Sets fullscreen property on mpv window |
POST |
/api/player/subtitle |
{ id: number | "no" } |
Sets active subtitle track by ID, or "no" to disable |
POST |
/api/player/subtitle-offset |
{ offset: number } |
Sets the subtitle delay in seconds (sub-delay in mpv). Negative values make subtitles appear earlier, positive values later |
| Export | Description |
|---|---|
useMediaFiles(path) |
Fetches /api/media. Returns MediaContent — groups of folders with their video files |
useDirectoryContent(path) |
Fetches /api/browse. Returns raw directory entries with type and size |
MediaFile |
{ name, path, size } |
MediaGroup |
{ name, path, files: MediaFile[] } |
MediaContent |
{ path, groups: MediaGroup[] } |
| Export | Description |
|---|---|
usePlayerStatus() |
Polls /api/player/status — every 1s while playing, every 3s while idle |
usePlayFile() |
Mutation — POSTs to /api/player/play with a file path |
usePausePlayer() |
Mutation — optimistically sets paused: true |
useResumePlayer() |
Mutation — optimistically sets paused: false |
useStopPlayer() |
Mutation — optimistically sets playing: false |
useSeekPlayer() |
Mutation — optimistically updates position (absolute seek) |
useSkipPlayer() |
Mutation — optimistically nudges position by ±N seconds (relative seek) |
useSetVolume() |
Mutation — optimistically updates volume |
useSetFullscreen() |
Mutation — optimistically updates fullscreen |
useSetSubtitle() |
Mutation — optimistically updates selected on the subtitles array |
useAdjustSubtitleOffset() |
Mutation — optimistically updates subtitleOffset; POSTs absolute offset in seconds to /api/player/subtitle-offset |
PlayerStatus |
Full status shape returned by the status endpoint |
SubtitleTrack |
{ id, title, lang, selected } |
Zustand store persisted to localStorage under the key theme-storage.
| Field / method | Description |
|---|---|
theme |
"light" | "dark" |
setTheme(theme) |
Sets theme explicitly |
toggleTheme() |
Flips between light and dark |
The root component. Responsibilities:
- Renders the header (logo, folder count, theme toggle)
- Search input that filters groups and files client-side
- Renders
DirectoryGroupcards, each collapsible, containingMediaFileRowitems - Clicking a file opens
PlayDialog(confirmation before sending play request) - Renders
PlayerBarat the bottom whenplayerStatus.playing === true
The persistent playback control bar. Rendered in three rows:
- Playback controls — skip back 10s, play/pause, skip forward 10s, stop (centred)
- Seek scrubber — draggable/tappable progress bar (pointer capture for mobile drag)
- Info + secondary controls — file icon, title, timestamp / volume slider+mute / subtitle dropdown / fullscreen toggle
The More Controls dialog (opened via the sliders icon in the playback row) contains additional controls:
- Subtitle Offset —
−/+buttons adjust the subtitle delay by 0.1 s per tap; a Reset button returns it to 0. The current offset is displayed and highlighted in primary colour when non-zero. - Display — sets the connected display to 1920×1080 via
xrandr.
Reads useThemeStore and applies the "light" or "dark" class to document.documentElement. Also checks prefers-color-scheme on first load if no saved preference exists.
- All colours use CSS variable-based Tailwind classes —
bg-background,text-foreground,bg-muted,text-muted-foreground,bg-primary,text-primary-foreground,bg-card,border-border,bg-accent,text-destructive, etc. - Never use hardcoded colour classes like
bg-zinc-900ortext-gray-500. This ensures light/dark theme switching works correctly throughout. - CSS variables for both themes are defined in
src/index.css.
| Alias | Resolves to |
|---|---|
@ |
./src |
@shared |
./packages |
