Small Android service that exposes simple HTTP endpoints to remote-control whatever is currently playing on the phone (Finamp, Jellyfin, Spotify, etc.) and to read now-playing metadata & album art. Designed to pair with a PWA or other controller running on the same LAN/VPN.
- Controls: play/pause, previous/next, seek, volume ±
- Metadata: JSON snapshot of the active
MediaSession(title/artist/album/duration/position) - Album art: served as JPEG
- CORS-friendly: responds to preflight, wide-open
Access-Control-*headers - Auth: shared secret via
X-Secretheader per request
⚠️ Intended for LAN/VPN use. If you expose it to the internet, put it behind TLS and an allow-list / reverse proxy.
The app runs a foreground Service (BridgeService) that:
- Hosts a tiny embedded HTTP server (
TinyHttpServer) - Uses a
NotificationListenerService(NotifAccess) to read activeMediaSessions - Invokes
MediaController.TransportControlsfor commands - Reads
MediaMetadatafor track info and artwork
All responses include permissive CORS headers and OPTIONS preflight support.
All endpoints require the header: X-Secret: <your secret>
(Preflight OPTIONS does not. See Art in browsers below.)
POST /play
POST /pause
POST /toggle
POST /next
POST /prev
POST /seek?ms=<milliseconds>
POST /volup
POST /voldown
seek: absolute position in milliseconds- Response:
200 okon success,400 no active sessionif nothing to control
GET /sessions
Returns an array of visible sessions (package, state, title, artist) — useful for debugging.
GET /nowplaying
Returns a JSON snapshot:
{
"hasSession": true,
"clientPackage": "com.xxxx.finamp",
"clientLabel": "Finamp",
"isPlaying": true,
"title": "Track Title",
"artist": "Artist",
"album": "Album",
"durationMs": 247000,
"positionMs": 122000,
"volume": 9,
"maxVolume": 15
}GET /art
Returns current art as image/jpeg.
Art in browsers:
<img src>can’t send custom headers, so yourX-Secretwon’t reach the server. Fetch the image as a blob withX-Secret, then setimg.src = URL.createObjectURL(blob)(see example below).
Access-Control-Allow-Origin: *Access-Control-Allow-Headers: X-Secret, Content-TypeAccess-Control-Allow-Methods: POST, GET, OPTIONSCache-Control: no-store
OPTIONS replies 204 No Content with the same CORS headers.
- Open in Android Studio.
- Recommended minSdk 28+, target latest stable.
- Run on your phone (debug build is fine).
- Open the app.
- Set a strong Bridge Secret and (optionally) change Port (default
8765). - Tap Save — the service restarts. Status shows
HTTP: http://<phone-ip>:<port>.
- The app prompts for it, or go to:
Settings → Notifications → Notification access and enable JF Bridge Notification Access.
Without this, no sessions are visible (you’ll see
400 no active session).
# Toggle play/pause
curl -i -X POST http://PHONE_IP:8765/toggle -H 'X-Secret: yoursecret'
# Now playing JSON
curl -i http://PHONE_IP:8765/nowplaying -H 'X-Secret: yoursecret'
# Save album art
curl -o art.jpg http://PHONE_IP:8765/art -H 'X-Secret: yoursecret'// Control
await fetch(`http://${phone}:8765/toggle`, {
method: 'POST',
headers: { 'X-Secret': secret }
});
// Now playing
const np = await fetch(`http://${phone}:8765/nowplaying`, {
headers: { 'X-Secret': secret },
cache: 'no-store'
}).then(r => r.json());
// Art as blob (so we can send the header)
const res = await fetch(`http://${phone}:8765/art?ts=${Date.now()}`, {
headers: { 'X-Secret': secret },
cache: 'no-store'
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
img.src = url;
// later: URL.revokeObjectURL(url);Requested permissions:
INTERNET,ACCESS_NETWORK_STATE,WAKE_LOCKFOREGROUND_SERVICEandFOREGROUND_SERVICE_MEDIA_PLAYBACKPOST_NOTIFICATIONS(Android 13+, optional but recommended)
Android 12+ requires calling startForeground() from onStartCommand() (not onCreate()). This project follows that.
BridgeService.kt– Foreground service; starts/stops HTTP server; executes media commandsTinyHttpServer.kt– Minimal HTTP server with routing, CORS, preflight, JSON/binary writersNotifAccess.kt–NotificationListenerServiceto query activeMediaSessionsUtils.kt– small helpers (e.g., current IP)AndroidManifest.xml– permissions, services, notification channel, optional cleartext LAN setting
-
Auth is a shared secret in the
X-Secretheader. Choose a strong random value. -
Do not expose this plain-HTTP service directly to the internet.
-
If remote access is required, use a reverse proxy with:
- TLS
- IP allow-listing or VPN
- Optional extra auth (e.g., basic auth)
-
401 Unauthorized Missing/incorrect
X-Secret. For album art, ensure you fetch as a blob so the header is included. -
400 no active session No visible
MediaSessionor notification access not granted. Start playback in the app and verify access. -
Service start errors / “ForegroundServiceStartNotAllowed” Ensure notifications are permitted on Android 13+; this repo calls
startForeground()inonStartCommand(). -
Controls work but metadata doesn’t update Some apps delay metadata. The bridge snapshots the top active session; try a quick pause/play. Confirm
NotifAccessis enabled.
GPL-3.0
- Android
MediaSessionManager/MediaControllerAPIs - Inspired by controlling Jellyfin/Finamp sessions from a lightweight PWA