A Spotify Connect receiver for Android with an ambient, lyrics-aware now-playing screen — now grown into a small, always-on appliance. Open the app and the device starts advertising itself on your local network, appearing as a speaker target in any Spotify client (phone, desktop, web) on the same Wi‑Fi. Pick it, and audio streams directly to the device. When nothing is playing it settles into a screensaver, and it can double as a full-screen Home Assistant dashboard.
Built on Rust — and built to give new life to rusty devices. Runs great on always-on screens like the Amazon Echo Show, and on any Android 8.0+ device.
rusty-showcase.mp4
| Now playing | Synced lyrics | Screensaver · Clock |
|---|---|---|
![]() |
![]() |
![]() |
| Screensaver · OLED | Home Assistant | On-screen launcher |
|---|---|---|
![]() |
![]() |
![]() |
| Settings | Services & status | Cameras |
|---|---|---|
![]() |
![]() |
![]() |
| Control page | Control page on a phone |
|---|---|
![]() |
![]() |
Captured on an Amazon Echo Show 8 (1280×800), except Services & status, which is from a Lenovo Tab M10 because that page is taller than an 800 px screen. Cover art is a generated gradient and the track, artist, listener and lyrics are placeholders — no copyrighted content. The Home Assistant shot uses the public Home Assistant demo. The Cameras shot shows four illustrated test scenes served from a laptop, not real cameras, and the control page shots were taken in a browser against a tablet running Rusty with those same test cameras.
- Spotify Connect target — zero-config discovery; appears automatically in Spotify clients on the same network.
- Direct streaming playback — high-bitrate audio decoded on-device via librespot (Rust), output through cpal's native AAudio backend.
- Follows the active audio route — output reopens automatically when the route changes (e.g. connecting/disconnecting a Bluetooth speaker or headset mid-playback), so audio moves with it instead of going silent.
- Ambient now-playing UI — album-art color wash, drifting mesh background, accent-aware theming, and a calm idle clock face when nothing is playing.
- Synced lyrics — time-aligned lyrics that scroll with the track, the active line highlighted.
- Transport controls — play / pause / next / previous from the device itself.
- Live device rename — change the receiver's broadcast name from Settings; it re-advertises immediately, no restart.
- Tunable — pick streaming bitrate (96 / 160 / 320 kbps), a fullscreen "hide system bars" mode, and 12/24-hour clock.
- Shows your Spotify display name while connected.
- Screensaver — after an idle timeout (or a tap on the clock) Rusty shows a full-screen idle face and gently wakes back to now-playing. Pick a clean Clock face, an OLED-burn-in-safe drifting face, a Spotify Canvas face that plays the track's looping Canvas video, or an Immich Slideshow face.
- Immich Slideshow — turn the idle screen into your own photo frame: point Rusty at a self-hosted Immich server and it shows your library, or just the albums, people or tags you pick, with slow Ken Burns motion, a blurred fill, an optional clock and photo info, and pause / next / previous from the screen or a remote. The key it needs is read-only — see Immich API key permissions.
- Home Assistant dashboard — an optional second screen: sign in from Rusty's settings (or through the dashboard's own login) and Rusty shows your Home Assistant dashboards full-screen in a kiosk-style view, with switcher chips to jump between them. It auto-discovers your dashboards and sidebar apps, and can tint its own chrome to match your dashboard theme.
- Home Assistant media renderer — optionally expose Rusty as a DLNA media player that Home Assistant auto-discovers as a
media_playerentity (nothing to install on the HA side). Speak TTS announcements ("the wash is done", a doorbell chime, a morning briefing) or stream internet radio to it from automations, scripts, or a dashboard card — Rusty pauses or fades Spotify while the message plays and resumes it afterwards. - Cameras — a wall of your RTSP cameras: snapshot tiles that refresh on a timer, and a tap or OK away a full-screen live view with sound, a sub/main stream switch and a snapshot button that saves to Pictures/Rusty. Add cameras by scanning the network (ONVIF), by address, or by hand; drag to reorder; show them all at once or in pages of 4, 6 or 8 that can turn on their own. Off by default. See Cameras.
- Spotify Canvas in now-playing — optionally fill the now-playing screen with the track's looping Canvas video instead of static album art.
- Remote control — an optional, off-by-default web page and HTTP API the device serves itself: switch what Rusty is showing (Spotify, Home Assistant, DLNA or the lock screen), bring its window forward or send it away, pick the lock screen's theme, turn the screen on/off, set brightness and media volume, and edit the Slideshow's album/person/tag filters from your phone or laptop. While it's on, the device announces itself on the network so a Home Assistant integration can discover it. See Remote control.
- Playback takeover — optionally have Rusty react when a phone or laptop starts playing on this receiver: switch the app to the Spotify page, and wake the screen and bring Rusty to the front. Two toggles in Settings → Spotify, both off by default. See Playback takeover.
- Services & status — one page showing every service and feature at a glance, each with its state, name and address, reachable from the info button on any screen.
- Update from the device — check for a new release and install it from About & updates, or from the control page in another room. Android always asks for confirmation on the device screen.
- On-screen launcher — an expandable button jumps between Spotify, Home Assistant, and the screensaver.
- Start on boot & Keep screen on — optional toggles to launch Rusty when the device powers on and to hold the display awake while it's in front.
- Tabbed settings — each feature gets its own settings page.
- Spotify Premium — Spotify Connect requires a Premium account.
- Android 8.0 (API 26) or newer.
- A 64-bit (arm64-v8a) or 32-bit ARM (armeabi-v7a) device. (No x86 builds are shipped.)
- The receiver and the controlling Spotify client must be on the same local network.
- Home Assistant mode (optional) needs a Home Assistant instance reachable on the same local network.
- Immich Slideshow (optional) needs a self-hosted Immich server reachable on the same local network, plus an API key (see below).
- Remote control (optional) is off by default and needs nothing but a browser on the same local network — read the security note before enabling it.
Tested on an Amazon Echo Show 8 running LineageOS 18.1 (Android 11) and on a Lenovo Tab M10 (TB-X606FA).
Create the key in Immich under Account settings → API keys, and grant it these read permissions:
album.read
album.statistics
asset.view
asset.read
asset.statistics
face.read
memory.read
person.read
person.statistics
tag.read
user.read
Turn the feature on in Settings → General → Cameras, then open Settings → Cameras to add
them. Scan this Wi-Fi network finds ONVIF cameras and fills in their stream for you; a camera
on another subnet can be added by address (ONVIF on port 8000 or 80); anything else takes an
rtsp:// URL by hand. Test probes each stream and names the codec, so you know the device
can decode it before you save. Usernames and passwords are kept in Rusty's encrypted store, never
in the stream URL.
The wall shows a still per camera, taken from the camera's snapshot URL when it has one and otherwise grabbed from the stream. Pick All in one view, where tiles shrink to fit, or Pages of 4, 6 or 8 with bigger tiles, flipped with ◀ ▶ or on a timer. The refresh runs one camera at a time and starts at 30 s: grabbing a frame from a stream costs a few seconds of decoding, so a faster setting would keep the device busy without showing you more. Tap a tile or press OK for the live view: sound if the camera has it, a SUB | MAIN switch when a high-resolution stream is set, and a snapshot button that saves to Pictures/Rusty.
A live view with sound pauses Spotify while it is up and resumes it afterwards. Streams use RTSP over TCP by default; turn Force TCP off per camera only if yours needs UDP.
Rusty can serve a small control page — and the HTTP API behind it — from the device itself, so you can drive the screen from another room without walking over to it.
It is off by default. Turn it on in Settings → General → Remote control. The same row
then shows the address to open, something like http://192.168.1.42:8765/. Type that into any
browser on the same network and you get a single page with:
- Source — four lamps for the four things Rusty can be showing: Spotify, Home Assistant, DLNA and the lock screen. Tap one and the device switches to it. A lamp only lights once the device confirms the switch, so a command that didn't land never looks like it did; a feature you've switched off in Settings stays in place, struck through, rather than disappearing. Switching needs Rusty to be on screen — if it isn't, the row says so instead of pretending.
- Cameras — when the Cameras feature is on, the Source row gains a Camera lamp and, while it
is lit, a strip of your cameras: tap one to show it full screen on the device, or Grid to
go back to the wall. With the password on, the API also serves each camera's latest still at
/api/camera/<id>/snapshot. - On screen — a switch that brings Rusty's window to the front, or sends it out of the way to whatever's behind it. Bringing it forward wakes the display first, so it works on a sleeping panel. Both directions need Rusty to hold Android's "Display over other apps" permission — and the switch is deliberately dead in both directions without it, because sending Rusty away when it can't come back would leave a touch-free screen with no way home.
- Lock screen theme — pick Clock, OLED, Canvas or Slideshow. This one works even when Rusty isn't in the foreground, because it's a saved preference: a lock screen that appears later uses it, and one that's already up swaps instantly.
- Screen — on/off and a brightness slab you can drag anywhere on. "Off" is a full-screen black overlay that keeps the panel awake, so turning it back on is instant; touching the device (or pressing any remote key) also wakes it.
- Volume — the media volume slab. Hidden on devices whose volume is fixed (some TVs and docks).
- Slideshow sources — the same album / person / tag checklists as the in-app picker, so you can re-aim the photo frame from the sofa. Collapsed under Service, since it's a setup task rather than something you do daily.
- Software — see whether a newer Rusty release exists and start the download from your sofa, also under Service. The device fetches the APK itself and hands it to Android's installer; Android always asks for confirmation on the device screen (a sideloaded app can't update itself silently), so the last step is one OK on the device — by touch or D-pad. The very first time, Android also shows a one-time "allow installs from this source" screen for Rusty.
The port is fixed at 8765. Nothing needs to be installed on the other device — it's one self-contained page, no accounts, no cloud.
Under the toggle you may see a row asking to allow system brightness. It opens Android's Modify system settings screen for Rusty. Granting it lets the brightness slider move the device's real display brightness; without it, Rusty can only dim its own window, which looks the same from across the room but doesn't affect anything else on screen. The control page tells you which mode is in effect. It is entirely optional, and Remote control works without it. While the grant is missing the Remote control switch itself shows amber rather than green — the service is running and everything else works; the amber only flags the unclaimed brightness permission.
While Remote control is on, Rusty advertises itself as _rusty._tcp over mDNS so a Home
Assistant integration can discover it on the network and expose the screen, volume, playing
state and the active panel as entities. That integration is a separate project; Rusty itself needs no configuration
for it beyond the toggle.
The API and control page are open by default: no password, PIN or token. That is a deliberate
choice for a device that lives on a home network, and you can change it. Settings → Remote
Control → Require password sets a password that every request must carry (the control page asks
for it once and remembers it in that browser; scripts send it as
Authorization: Bearer <password>). Camera stills over the API are only served
at all while the password is on. With the password off, this is what "open" means:
- Any client on your local network can control this device: switch the screen on or off, change brightness and media volume, and change the Slideshow filters. It can also read the names of your Immich albums, people and tags (names only — no photos are served through this API, and your Immich API key never leaves the device). Camera names are visible too, though stills are refused without the password. It can also start an app update — the worst that does is pop the system's install prompt on the device screen, because the APK always comes from Rusty's own GitHub Releases (the URL is pinned in the app, not taken from the request) and nothing installs without the on-device confirmation.
- Any app already installed on the device that holds the
INTERNETpermission can do the same, becauselocalhost/127.0.0.1are deliberately accepted as valid hosts (that is what makesadb forwarddebugging work). This isn't a new class of exposure — a local app could already reach any server on the LAN — but it is worth knowing. - Browser-based attacks are guarded against: Rusty validates the
Hostheader (so a page on the public internet can't use DNS rebinding to reach it), never emits CORS headers, requiresContent-Type: application/jsonon writes, and serves nothing but the one embedded page and the fixed API routes.
So: leave it off unless you want it, set the password if anyone you don't fully trust shares the network, and don't enable it at all on a network you don't trust — a guest Wi-Fi, a shared flat, a café. If you need it reachable from outside your home, put it behind your own VPN rather than forwarding port 8765.
Settings → Spotify has two toggles, both off by default: Switch to Spotify on playback and Wake and show Rusty on playback. Both react only to a genuine new play started from another device — renaming the receiver, changing bitrate, or a plain pause/resume won't trigger them.
Wake and show Rusty is one gesture rather than a screen switch and an app switch, because the
halves are not separately useful: an app launched while the display is off may never resume, so
waking and coming forward only make sense together. It needs Android's Display over other apps
permission (SYSTEM_ALERT_WINDOW), and it is all-or-nothing — without the grant it does nothing at
all, not even the wake it could technically perform.
Turning it on without the permission opens the system grant screen directly, and until the grant lands the switch shows amber rather than green, with the reason under it. On devices that ship no such screen — common on Android TV and Fire OS builds — the toggle disables itself with an explanation instead, since there is nothing to send you to.
Holding that permission is a documented background-activity-launch exemption on Android 10–15, but Android 14–16 have progressively hardened background launches, and some OEM builds ignore the exemption regardless. A blocked launch is swallowed silently by the platform — there is no way for Rusty to detect it — so on an affected device the toggle quietly degrades to a wake plus a page switch: the screen still lights, and the Spotify page is ready and waiting the next time you open the app, with the existing media notification as the manual way to bring it forward.
- Go to the Releases page.
- Download the
.apkfor the latest release (e.g.rusty-v2.0.0.apk). - Sideload it onto your device:
(Or enable "Install unknown apps" and open the APK directly on the device.)
adb install -r rusty-v2.0.0.apk
- Launch the app — it begins advertising as a Connect target right away.
The published APK is debug-signed (built with
assembleDebug). It installs and runs fine for sideloading; if you later switch to a release-signed build, uninstall first to avoid a signature conflict on upgrade.
Toolchain: JDK 17, Android SDK (compileSdk 36), Gradle 8.13 (via the wrapper), AGP 8.13.2, Kotlin 2.0.21.
git clone https://github.com/SerafiniJose/rusty.git
cd rusty
./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apkThe Android build consumes prebuilt native libraries committed under
app/src/main/jniLibs/{arm64-v8a,armeabi-v7a}/libspotify_receiver_core.so, so you do not
need the Rust toolchain to build the APK.
The native core lives in rust/. It cross-compiles with
cargo-ndk, which writes the refreshed .so
files straight into jniLibs for both ABIs:
cargo install cargo-ndk # one-time
rustup target add aarch64-linux-android armv7-linux-androideabi
export ANDROID_NDK_HOME=/path/to/ndk # NDK r27+
cd rust
cargo ndk -t armeabi-v7a -t arm64-v8a --platform 26 \
-o ../app/src/main/jniLibs build --release
--platform 26is required. The audio path is cpal's AAudio backend, which linkslibaaudio.so— and the NDK ships that library only for API ≥ 26 (which is also the app'sminSdk). Omitting it fails to link withunable to find library -laaudio.
The JNI symbol names (
Java_dev_rusty_app_NativeBridge_*) are derived from the app package. If you ever change the package, the native symbols must be regenerated to match.
flowchart TD
client["Spotify client<br/>phone · desktop · web — same Wi-Fi"]
client -- "Connect / zeroconf" --> shell
subgraph shell["Rusty · feature shell — Kotlin (HomeActivity)"]
direction LR
spotify["Spotify<br/>now playing · lyrics · idle"]
dlna["DLNA player<br/>TTS / radio from Home Assistant"]
screensaver["Screensaver<br/>Clock · OLED · Canvas · Immich Slideshow"]
homeassistant["Home Assistant<br/>kiosk WebView → your instance"]
end
spotify -- "JNI (Spotify feature only)" --> core["Rust core — librespot 0.8<br/>session · player · audio backend"]
- The app is a small feature shell (
HomeActivity) that hosts switchable, full-screen features — the Spotify receiver, the screensaver, and Home Assistant — under one shared chrome (clock, settings, on-screen launcher). - Kotlin (
app/) handles the UI, the foreground service, network advertising, and the now-playing / lyrics / settings / screensaver screens. Home Assistant is a kiosk WebView pointed at your own instance — no Rust involved. - Rust (
rust/) wraps librespot 0.8 and exposes a small JNI surface (NativeBridge) for session lifecycle, transport, token retrieval, and rename — used only by the Spotify feature.
- Built on librespot (MIT) — the open-source Spotify client library that does the real protocol and audio work.
- Originally inspired by willturr/librespot-android-connect, a proof-of-concept that demonstrated driving librespot from Android over JNI.
- Home Assistant dashboard icons are rendered with the Material Design Icons webfont by the Pictogrammers group (fonts under the Apache 2.0 license).
- The Home Assistant screen embeds your own Home Assistant instance (an open-source home-automation platform; this project is not affiliated with it).
- Downloadable announcement voices run on sherpa-onnx
(Apache 2.0), whose prebuilt Android library is checked in under
app/libs/. The voices themselves are Piper models repackaged by the sherpa-onnx project; each voice carries its own dataset license and attribution, shown in the app's voice catalog before download. - The Immich Slideshow connects to your own self-hosted Immich server (source) — an open-source, self-hosted photo and video library. Rusty reads your photos through Immich's API with a read-only key and bundles none of its code; this project is not affiliated with it.
This is an unofficial, independent project. It is not affiliated with, authorized, or endorsed by Spotify. "Spotify" is a trademark of Spotify AB. You need your own Spotify Premium account to use it, and you are responsible for complying with Spotify's Terms of Service. Provided as-is, for personal and educational use.
Everything in this repository is licensed under the MIT license.










